comit inicial
1
Recolector/app/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
79
Recolector/app/build.gradle.kts
Normal file
@@ -0,0 +1,79 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
id("com.google.gms.google-services")
|
||||
}
|
||||
|
||||
// Redirige build fuera de OneDrive para evitar bloqueos de sincronización
|
||||
layout.buildDirectory.set(File("C:/GradleBuild/Recolector/app"))
|
||||
|
||||
android {
|
||||
namespace = "com.itc.recolector"
|
||||
compileSdk {
|
||||
version = release(36) {
|
||||
minorApiLevel = 1
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.itc.recolector"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
// Retrofit para APIs
|
||||
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||
// Coroutines
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
// ViewModel
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||
// DataStore para guardar tokens
|
||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||
// Navigation
|
||||
implementation("androidx.navigation:navigation-compose:2.7.6")
|
||||
// FCM
|
||||
implementation("com.google.firebase:firebase-messaging-ktx:23.4.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3")
|
||||
}
|
||||
29
Recolector/app/google-services.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "1056143599455",
|
||||
"project_id": "recolector-f70b2",
|
||||
"storage_bucket": "recolector-f70b2.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:1056143599455:android:bd4424f7b49f6649f138ea",
|
||||
"android_client_info": {
|
||||
"package_name": "com.itc.recolector"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyBz5C70NRP_3RS5IWDHGIC9xKo3D97VUJc"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
21
Recolector/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.itc.recolector
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.itc.recolector", appContext.packageName)
|
||||
}
|
||||
}
|
||||
35
Recolector/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Recolector"
|
||||
android:usesCleartextTraffic="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.Recolector">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service
|
||||
android:name=".service.RecolectaFirebaseService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.itc.recolector
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.itc.recolector.data.local.TokenDataStore
|
||||
import com.itc.recolector.data.remote.api.AdminApi
|
||||
import com.itc.recolector.data.remote.api.AuthApi
|
||||
import com.itc.recolector.data.remote.api.CamioneroApi
|
||||
import com.itc.recolector.data.remote.api.CiudadanoApi
|
||||
import com.itc.recolector.di.NetworkModule
|
||||
import com.itc.recolector.ui.admin.AdminViewModel
|
||||
import com.itc.recolector.ui.auth.AuthViewModel
|
||||
import com.itc.recolector.ui.camionero.CamioneroViewModel
|
||||
import com.itc.recolector.ui.ciudadano.CiudadanoViewModel
|
||||
import com.itc.recolector.ui.navigation.AppNavigation
|
||||
import com.itc.recolector.ui.theme.RecolectorTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
setContent {
|
||||
RecolectorTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
|
||||
// DataStore
|
||||
val dataStore = remember {
|
||||
TokenDataStore(applicationContext)
|
||||
}
|
||||
|
||||
// APIs
|
||||
val authApi = remember { NetworkModule.provideAuthApi(dataStore) }
|
||||
val ciudadanoApi =
|
||||
remember { NetworkModule.provideCiudadanoApi(dataStore) }
|
||||
val camioneroApi = remember { NetworkModule.provideCamioneroApi(dataStore) }
|
||||
val adminApi = remember { NetworkModule.provideAdminApi(dataStore) }
|
||||
|
||||
// ViewModels
|
||||
val authViewModel = remember {
|
||||
AuthViewModel(authApi, dataStore)
|
||||
}
|
||||
val ciudadanoViewModel = remember {
|
||||
CiudadanoViewModel(ciudadanoApi, dataStore)
|
||||
}
|
||||
val camioneroViewModel = remember {
|
||||
CamioneroViewModel(camioneroApi)
|
||||
}
|
||||
val adminViewModel = remember {
|
||||
AdminViewModel(adminApi)
|
||||
}
|
||||
|
||||
AppNavigation(
|
||||
navController = navController,
|
||||
dataStore = dataStore,
|
||||
authViewModel = authViewModel,
|
||||
ciudadanoViewModel = ciudadanoViewModel,
|
||||
camioneroViewModel = camioneroViewModel,
|
||||
adminViewModel = adminViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.itc.recolector.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
// data/local/TokenDataStore.kt
|
||||
class TokenDataStore(private val context: Context) {
|
||||
|
||||
private val Context.dataStore by preferencesDataStore("recolecta_prefs")
|
||||
|
||||
companion object {
|
||||
val ACCESS_TOKEN = stringPreferencesKey("access_token")
|
||||
val REFRESH_TOKEN = stringPreferencesKey("refresh_token")
|
||||
val USER_ROL = stringPreferencesKey("user_rol")
|
||||
val USER_NOMBRE = stringPreferencesKey("user_nombre")
|
||||
val USER_EMAIL = stringPreferencesKey("user_email")
|
||||
}
|
||||
|
||||
suspend fun saveAuth(
|
||||
accessToken: String,
|
||||
refreshToken: String,
|
||||
rol: String,
|
||||
nombre: String,
|
||||
email: String?
|
||||
) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[ACCESS_TOKEN] = accessToken
|
||||
prefs[REFRESH_TOKEN] = refreshToken
|
||||
prefs[USER_ROL] = rol
|
||||
prefs[USER_NOMBRE] = nombre
|
||||
if (email != null) prefs[USER_EMAIL] = email
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getAccessToken(): String? {
|
||||
return context.dataStore.data.first()[ACCESS_TOKEN]
|
||||
}
|
||||
|
||||
suspend fun getRol(): String? {
|
||||
return context.dataStore.data.first()[USER_ROL]
|
||||
}
|
||||
|
||||
suspend fun clearAll() {
|
||||
context.dataStore.edit { it.clear() }
|
||||
}
|
||||
|
||||
fun isLoggedIn(): Flow<Boolean> {
|
||||
return context.dataStore.data.map { prefs ->
|
||||
prefs[ACCESS_TOKEN] != null
|
||||
}
|
||||
}
|
||||
|
||||
fun getRolFlow(): Flow<String?> {
|
||||
return context.dataStore.data.map { prefs ->
|
||||
prefs[USER_ROL]
|
||||
}
|
||||
}
|
||||
|
||||
// Agregar estos métodos al TokenDataStore existente
|
||||
|
||||
suspend fun getRefreshToken(): String? {
|
||||
return context.dataStore.data.first()[REFRESH_TOKEN]
|
||||
}
|
||||
|
||||
suspend fun getNombre(): String? {
|
||||
return context.dataStore.data.first()[USER_NOMBRE]
|
||||
}
|
||||
|
||||
suspend fun getEmail(): String? {
|
||||
return context.dataStore.data.first()[USER_EMAIL]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.itc.recolector.data.remote.api
|
||||
|
||||
import com.itc.recolector.data.remote.dto.response.ApiResponse
|
||||
import com.itc.recolector.data.remote.dto.response.DashboardAdminResponse
|
||||
import com.itc.recolector.data.remote.dto.response.OperadorStatsResponse
|
||||
import com.itc.recolector.data.remote.dto.response.RankingOperadorResponse
|
||||
import com.itc.recolector.data.remote.dto.response.RutaStatusResponse
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
|
||||
interface AdminApi {
|
||||
|
||||
@GET("api/admin/dashboard")
|
||||
suspend fun dashboard(): ApiResponse<DashboardAdminResponse>
|
||||
|
||||
@GET("api/admin/rutas")
|
||||
suspend fun rutas(): ApiResponse<List<RutaStatusResponse>>
|
||||
|
||||
@GET("api/admin/rutas/{routeId}/status")
|
||||
suspend fun rutaStatus(
|
||||
@Path("routeId") routeId: String
|
||||
): ApiResponse<RutaStatusResponse>
|
||||
|
||||
@GET("api/admin/rutas/{routeId}/combustible")
|
||||
suspend fun combustible(
|
||||
@Path("routeId") routeId: String
|
||||
): ApiResponse<Any>
|
||||
|
||||
@GET("api/admin/operadores/ranking")
|
||||
suspend fun ranking(): ApiResponse<List<RankingOperadorResponse>>
|
||||
|
||||
@GET("api/admin/operadores/{id}/stats")
|
||||
suspend fun operadorStats(
|
||||
@Path("id") id: Long
|
||||
): ApiResponse<OperadorStatsResponse>
|
||||
|
||||
@GET("api/admin/incidencias")
|
||||
suspend fun incidencias(): ApiResponse<List<Any>>
|
||||
|
||||
@GET("api/admin/incidencias/ruta/{routeId}")
|
||||
suspend fun incidenciasPorRuta(
|
||||
@Path("routeId") routeId: String
|
||||
): ApiResponse<List<Any>>
|
||||
|
||||
@POST("api/admin/demo/ruta/{routeId}/avanzar")
|
||||
suspend fun avanzarRuta(
|
||||
@Path("routeId") routeId: String
|
||||
): ApiResponse<String>
|
||||
|
||||
@POST("api/admin/demo/ruta/{routeId}/reiniciar")
|
||||
suspend fun reiniciarRuta(
|
||||
@Path("routeId") routeId: String
|
||||
): ApiResponse<String>
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.itc.recolector.data.remote.api
|
||||
|
||||
import com.itc.recolector.data.remote.dto.request.LoginRequest
|
||||
import com.itc.recolector.data.remote.dto.request.RegisterRequest
|
||||
import com.itc.recolector.data.remote.dto.response.ApiResponse
|
||||
import com.itc.recolector.data.remote.dto.response.AuthResponse
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.POST
|
||||
|
||||
interface AuthApi {
|
||||
|
||||
@POST("api/auth/register")
|
||||
suspend fun register(
|
||||
@Body request: RegisterRequest
|
||||
): ApiResponse<AuthResponse>
|
||||
|
||||
@POST("api/auth/login")
|
||||
suspend fun login(
|
||||
@Body request: LoginRequest
|
||||
): ApiResponse<AuthResponse>
|
||||
|
||||
@POST("api/auth/refresh")
|
||||
suspend fun refresh(
|
||||
@Header("Refresh-Token") refreshToken: String
|
||||
): ApiResponse<AuthResponse>
|
||||
|
||||
@POST("api/auth/logout")
|
||||
suspend fun logout(
|
||||
@Header("Refresh-Token") refreshToken: String
|
||||
): ApiResponse<Void>
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.itc.recolector.data.remote.api
|
||||
|
||||
import com.itc.recolector.data.remote.dto.request.EvaluacionRequest
|
||||
import com.itc.recolector.data.remote.dto.request.IncidenciaRequest
|
||||
import com.itc.recolector.data.remote.dto.response.ApiResponse
|
||||
import com.itc.recolector.data.remote.dto.response.BadgeResponse
|
||||
import com.itc.recolector.data.remote.dto.response.OperadorStatsResponse
|
||||
import com.itc.recolector.data.remote.dto.response.RutaStatusResponse
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
|
||||
interface CamioneroApi {
|
||||
|
||||
@GET("api/camionero/mi-ruta")
|
||||
suspend fun miRuta(): ApiResponse<RutaStatusResponse>
|
||||
|
||||
@POST("api/camionero/ruta/{routeId}/iniciar")
|
||||
suspend fun iniciarRuta(
|
||||
@Path("routeId") routeId: String
|
||||
): ApiResponse<String>
|
||||
|
||||
@POST("api/camionero/ruta/{routeId}/pausar")
|
||||
suspend fun pausarRuta(
|
||||
@Path("routeId") routeId: String
|
||||
): ApiResponse<String>
|
||||
|
||||
@POST("api/camionero/incidencias")
|
||||
suspend fun reportarIncidencia(
|
||||
@Body request: IncidenciaRequest
|
||||
): ApiResponse<Any>
|
||||
|
||||
@POST("api/camionero/evaluacion/{routeId}")
|
||||
suspend fun evaluarRuta(
|
||||
@Path("routeId") routeId: String,
|
||||
@Body request: EvaluacionRequest
|
||||
): ApiResponse<Any>
|
||||
|
||||
@GET("api/camionero/mis-stats")
|
||||
suspend fun misStats(): ApiResponse<OperadorStatsResponse>
|
||||
|
||||
@GET("api/camionero/mis-badges")
|
||||
suspend fun misBadges(): ApiResponse<List<BadgeResponse>>
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.itc.recolector.data.remote.api
|
||||
|
||||
import com.itc.recolector.data.remote.dto.request.DomicilioRequest
|
||||
import com.itc.recolector.data.remote.dto.response.ApiResponse
|
||||
import com.itc.recolector.data.remote.dto.response.DomicilioResponse
|
||||
import com.itc.recolector.data.remote.dto.response.ETAResponse
|
||||
import com.itc.recolector.data.remote.dto.response.NotificacionResponse
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
|
||||
interface CiudadanoApi {
|
||||
|
||||
@POST("api/ciudadano/domicilios")
|
||||
suspend fun registrarDomicilio(
|
||||
@Body request: DomicilioRequest
|
||||
): ApiResponse<DomicilioResponse>
|
||||
|
||||
@GET("api/ciudadano/domicilios")
|
||||
suspend fun listarDomicilios(): ApiResponse<List<DomicilioResponse>>
|
||||
|
||||
@GET("api/ciudadano/domicilios/{id}")
|
||||
suspend fun obtenerDomicilio(
|
||||
@Path("id") id: Long
|
||||
): ApiResponse<DomicilioResponse>
|
||||
|
||||
@DELETE("api/ciudadano/domicilios/{id}")
|
||||
suspend fun eliminarDomicilio(
|
||||
@Path("id") id: Long
|
||||
): ApiResponse<Any>
|
||||
|
||||
@GET("api/ciudadano/eta/{domicilioId}")
|
||||
suspend fun obtenerETA(
|
||||
@Path("domicilioId") domicilioId: Long
|
||||
): ApiResponse<ETAResponse>
|
||||
|
||||
@GET("api/ciudadano/notificaciones")
|
||||
suspend fun obtenerNotificaciones(): ApiResponse<List<NotificacionResponse>>
|
||||
|
||||
// rutaId es String (ej: "RUTA-01"), calificacion es Int 1-5
|
||||
@POST("api/ciudadano/calificacion/{rutaId}")
|
||||
suspend fun calificarServicio(
|
||||
@Path("rutaId") rutaId: String,
|
||||
@Body body: Map<String, Int>
|
||||
): ApiResponse<Any>
|
||||
|
||||
@POST("api/ciudadano/fcm-token")
|
||||
suspend fun actualizarFcmToken(
|
||||
@Body body: Map<String, String>
|
||||
): ApiResponse<Any>
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.itc.recolector.data.remote.dto.request
|
||||
|
||||
data class DomicilioRequest(
|
||||
val alias: String,
|
||||
val calle: String,
|
||||
val colonia: String,
|
||||
val codigoPostal: String?
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.itc.recolector.data.remote.dto.request
|
||||
|
||||
data class EvaluacionRequest(
|
||||
val llegoATiempo: Boolean,
|
||||
val tuvoIncidencia: Boolean
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.itc.recolector.data.remote.dto.request
|
||||
|
||||
data class IncidenciaRequest(
|
||||
val tipo: String,
|
||||
val descripcion: String,
|
||||
val routeId: String
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.itc.recolector.data.remote.dto.request
|
||||
|
||||
data class LoginRequest(
|
||||
val identifier: String,
|
||||
val password: String
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.itc.recolector.data.remote.dto.request
|
||||
|
||||
data class RegisterRequest(
|
||||
val nombre: String,
|
||||
val email: String?,
|
||||
val telefono: String?,
|
||||
val password: String,
|
||||
val rol: String
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.itc.recolector.data.remote.dto.response
|
||||
|
||||
data class ApiResponse<T>(
|
||||
val success: Boolean,
|
||||
val message: String,
|
||||
val data: T?,
|
||||
val error: String?,
|
||||
val timestamp: Any?
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.itc.recolector.data.remote.dto.response
|
||||
|
||||
data class AuthResponse(
|
||||
val accessToken: String,
|
||||
val refreshToken: String,
|
||||
val rol: String,
|
||||
val nombre: String,
|
||||
val email: String?
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.itc.recolector.data.remote.dto.response
|
||||
|
||||
data class BadgeResponse(
|
||||
val id: Long,
|
||||
val tipoBadge: String,
|
||||
val descripcion: String,
|
||||
val otorgadoAt: String
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.itc.recolector.data.remote.dto.response
|
||||
|
||||
data class DashboardAdminResponse(
|
||||
val totalRutasActivas: Int?,
|
||||
val totalRutasFinalizadas: Int?,
|
||||
val totalIncidenciasHoy: Int?,
|
||||
val combustibleTotalConsumido: Double?,
|
||||
val kmTotalRecorridos: Double?,
|
||||
val rutasEnCurso: List<RutaStatusResponse>,
|
||||
val topOperadores: List<RankingOperadorResponse>
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.itc.recolector.data.remote.dto.response
|
||||
|
||||
data class DomicilioResponse(
|
||||
val id: Long,
|
||||
val alias: String,
|
||||
val calle: String,
|
||||
val colonia: String,
|
||||
val codigoPostal: String?,
|
||||
val lat: Double,
|
||||
val lng: Double,
|
||||
val zonaCobertura: String,
|
||||
val routeId: String?,
|
||||
val horarioEstimado: String?
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.itc.recolector.data.remote.dto.response
|
||||
|
||||
data class ETAResponse(
|
||||
val routeId: String,
|
||||
val nombreRuta: String,
|
||||
val mensaje: String,
|
||||
val horaEstimadaInicio: String?,
|
||||
val horaEstimadaFin: String?,
|
||||
val minutosAproximados: Int?,
|
||||
val status: String,
|
||||
val posicionActual: Int,
|
||||
val totalPosiciones: Int
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.itc.recolector.data.remote.dto.response
|
||||
|
||||
data class NotificacionResponse(
|
||||
val id: Long,
|
||||
val tipoEvento: String,
|
||||
val titulo: String,
|
||||
val cuerpo: String,
|
||||
val enviadoAt: String
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.itc.recolector.data.remote.dto.response
|
||||
|
||||
data class OperadorStatsResponse(
|
||||
val camioneroId: Long,
|
||||
val nombre: String,
|
||||
val puntosTotales: Int,
|
||||
val rutasCompletadas: Int,
|
||||
val rutasATiempo: Int,
|
||||
val incidenciasReportadas: Int,
|
||||
val promedioCalificacion: Double,
|
||||
val totalBadges: Int,
|
||||
val badges: List<String>,
|
||||
val mes: Int?,
|
||||
val anio: Int?
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.itc.recolector.data.remote.dto.response
|
||||
|
||||
data class RankingOperadorResponse(
|
||||
val posicion: Int,
|
||||
val camioneroId: Long,
|
||||
val nombre: String,
|
||||
val puntosTotales: Int,
|
||||
val rutasCompletadas: Int,
|
||||
val rutasATiempo: Int,
|
||||
val mes: Int?,
|
||||
val anio: Int?
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.itc.recolector.data.remote.dto.response
|
||||
|
||||
data class RutaStatusResponse(
|
||||
val routeId: String,
|
||||
val nombre: String,
|
||||
val status: String,
|
||||
val posicionActual: Int,
|
||||
val totalPosiciones: Int,
|
||||
val ultimaActualizacion: String?,
|
||||
val kmRecorridos: Double?,
|
||||
val combustibleConsumido: Double?
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.itc.recolector.di
|
||||
|
||||
import com.itc.recolector.data.local.TokenDataStore
|
||||
import com.itc.recolector.data.remote.api.AuthApi
|
||||
import com.itc.recolector.data.remote.api.CiudadanoApi
|
||||
import com.itc.recolector.data.remote.api.CamioneroApi
|
||||
import com.itc.recolector.data.remote.api.AdminApi
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
|
||||
// di/NetworkModule.kt
|
||||
object NetworkModule {
|
||||
|
||||
private const val BASE_URL = "https://recolecta-hack2026.onrender.com/"
|
||||
// En emulador 10.0.2.2 apunta a localhost de tu PC
|
||||
// En dispositivo real usar IP de tu PC: "http://192.168.x.x:8080/"
|
||||
// Para render usar: "https://recolecta-hack2026.onrender.com/"
|
||||
|
||||
private fun provideOkHttpClient(dataStore: TokenDataStore): OkHttpClient {
|
||||
return OkHttpClient.Builder()
|
||||
.addInterceptor { chain ->
|
||||
val token = runBlocking { dataStore.getAccessToken() }
|
||||
val request = if (token != null) {
|
||||
chain.request().newBuilder()
|
||||
.addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
} else chain.request()
|
||||
chain.proceed(request)
|
||||
}
|
||||
.addInterceptor(HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
})
|
||||
.build()
|
||||
}
|
||||
|
||||
fun provideRetrofit(dataStore: TokenDataStore): Retrofit {
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(BASE_URL)
|
||||
.client(provideOkHttpClient(dataStore))
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
}
|
||||
|
||||
fun provideAuthApi(dataStore: TokenDataStore): AuthApi =
|
||||
provideRetrofit(dataStore).create(AuthApi::class.java)
|
||||
|
||||
fun provideCiudadanoApi(dataStore: TokenDataStore): CiudadanoApi =
|
||||
provideRetrofit(dataStore).create(CiudadanoApi::class.java)
|
||||
|
||||
fun provideCamioneroApi(dataStore: TokenDataStore): CamioneroApi =
|
||||
provideRetrofit(dataStore).create(CamioneroApi::class.java)
|
||||
|
||||
fun provideAdminApi(dataStore: TokenDataStore): AdminApi =
|
||||
provideRetrofit(dataStore).create(AdminApi::class.java)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// service/RecolectaFirebaseService.kt
|
||||
package com.itc.recolector.service
|
||||
|
||||
import android.util.Log
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
import com.itc.recolector.data.local.TokenDataStore
|
||||
import com.itc.recolector.data.remote.api.CiudadanoApi
|
||||
import com.itc.recolector.di.NetworkModule
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class RecolectaFirebaseService : FirebaseMessagingService() {
|
||||
|
||||
companion object {
|
||||
const val TAG = "FCM"
|
||||
// SharedFlow global para disparar el GET desde cualquier ViewModel
|
||||
val etaTrigger = kotlinx.coroutines.flow.MutableSharedFlow<String>(
|
||||
extraBufferCapacity = 1
|
||||
)
|
||||
}
|
||||
|
||||
// Llega push → dispara GET en el ViewModel
|
||||
override fun onMessageReceived(message: RemoteMessage) {
|
||||
super.onMessageReceived(message)
|
||||
|
||||
Log.d(TAG, "Push recibido: ${message.data}")
|
||||
|
||||
val tipoEvento = message.data["triggerEvent"] ?: return
|
||||
val routeId = message.data["routeId"] ?: return
|
||||
|
||||
Log.d(TAG, "Evento: $tipoEvento | Ruta: $routeId")
|
||||
|
||||
// Dispara el trigger — el ViewModel escucha esto
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
etaTrigger.emit(routeId)
|
||||
}
|
||||
}
|
||||
|
||||
// Token FCM nuevo → mandarlo al backend
|
||||
override fun onNewToken(token: String) {
|
||||
super.onNewToken(token)
|
||||
Log.d(TAG, "Nuevo FCM token: $token")
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
// Aquí actualizamos el token en el backend
|
||||
val api = NetworkModule.provideCiudadanoApi(
|
||||
TokenDataStore(applicationContext)
|
||||
)
|
||||
|
||||
api.actualizarFcmToken(mapOf("fcmToken" to token))
|
||||
Log.d(TAG, "FCM token actualizado en backend")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error actualizando FCM token: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
package com.itc.recolector.ui.admin
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DirectionsCar
|
||||
import androidx.compose.material.icons.filled.EmojiEvents
|
||||
import androidx.compose.material.icons.filled.LocalGasStation
|
||||
import androidx.compose.material.icons.filled.Logout
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Route
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material.icons.filled.SkipNext
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import com.itc.recolector.ui.auth.AuthViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AdminDashboardScreen(
|
||||
navController: NavController,
|
||||
viewModel: AdminViewModel,
|
||||
authViewModel: AuthViewModel
|
||||
) {
|
||||
val dashboard by viewModel.dashboard.collectAsState()
|
||||
val isLoading by viewModel.isLoading.collectAsState()
|
||||
val alertasFraude by viewModel.alertasFraude.collectAsState()
|
||||
|
||||
// Recargar al entrar a la pantalla (el token ya está guardado tras el login)
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.cargarDashboard()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
text = "Control Administrativo",
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = "Sistema Anti-Fraude IA",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.White.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color(0xFF0D1B2A)
|
||||
),
|
||||
actions = {
|
||||
IconButton(onClick = { viewModel.cargarDashboard() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = "Actualizar",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
authViewModel.logout {
|
||||
navController.navigate("login") {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Logout,
|
||||
contentDescription = "Salir",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(color = Color(0xFF0D1B2A))
|
||||
}
|
||||
return@Scaffold
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFFF5F5F5))
|
||||
.padding(padding),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
|
||||
// ===== HEADER IA =====
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
listOf(Color(0xFF0D1B2A), Color(0xFF1B263B))
|
||||
)
|
||||
)
|
||||
.padding(20.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Security,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFF00E5FF),
|
||||
modifier = Modifier.size(40.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "Motor Anti-Fraude Activo",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
Text(
|
||||
text = "Analizando patrones de combustible y GPS en tiempo real",
|
||||
color = Color(0xFF00E5FF).copy(alpha = 0.8f),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== ALERTAS DE FRAUDE =====
|
||||
if (alertasFraude.isNotEmpty()) {
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = Color(0xFFB71C1C)
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(6.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFFFFD700),
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "⚠️ ALERTAS DE FRAUDE DETECTADAS",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp,
|
||||
letterSpacing = 1.sp
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
alertasFraude.forEach { ruta ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(Color.White.copy(alpha = 0.15f))
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.LocalGasStation,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFFFFD700),
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = ruta.routeId,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = "Combustible: ${ruta.combustibleConsumido}L | Status: ${ruta.status}",
|
||||
color = Color.White.copy(alpha = 0.8f),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color(0xFFFFD700))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "REVISAR",
|
||||
color = Color(0xFFB71C1C),
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 11.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== KPIs PRINCIPALES =====
|
||||
dashboard?.let { d ->
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
KpiCard(
|
||||
modifier = Modifier.weight(1f),
|
||||
valor = "${d.totalRutasActivas}",
|
||||
label = "Rutas activas",
|
||||
icon = Icons.Filled.DirectionsCar,
|
||||
color = Color(0xFF1565C0)
|
||||
)
|
||||
KpiCard(
|
||||
modifier = Modifier.weight(1f),
|
||||
valor = "${d.totalIncidenciasHoy}",
|
||||
label = "Incidencias hoy",
|
||||
icon = Icons.Filled.Warning,
|
||||
color = if ((d.totalIncidenciasHoy ?: 0) > 3)
|
||||
Color(0xFFC62828) else Color(0xFFE65100)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
KpiCard(
|
||||
modifier = Modifier.weight(1f),
|
||||
valor = "${String.format("%.1f", d.combustibleTotalConsumido)}L",
|
||||
label = "Combustible total",
|
||||
icon = Icons.Filled.LocalGasStation,
|
||||
color = Color(0xFF6A1B9A)
|
||||
)
|
||||
KpiCard(
|
||||
modifier = Modifier.weight(1f),
|
||||
valor = "${String.format("%.0f", d.kmTotalRecorridos)}km",
|
||||
label = "Km recorridos",
|
||||
icon = Icons.Filled.Route,
|
||||
color = Color(0xFF2E7D32)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== RUTAS EN CURSO =====
|
||||
item {
|
||||
Text(
|
||||
text = "🚛 Rutas en Operación",
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = Color(0xFF0D1B2A)
|
||||
)
|
||||
}
|
||||
|
||||
items(d.rutasEnCurso) { ruta ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White),
|
||||
elevation = CardDefaults.cardElevation(2.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = ruta.routeId,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color(0xFF0D1B2A)
|
||||
)
|
||||
Text(
|
||||
text = ruta.nombre,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(
|
||||
when (ruta.status) {
|
||||
"EN_RUTA" -> Color(0xFF4CAF50)
|
||||
"PAUSADO" -> Color(0xFFFF9800)
|
||||
"FINALIZADO" -> Color(0xFF9E9E9E)
|
||||
else -> Color(0xFF2196F3)
|
||||
}
|
||||
)
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = ruta.status,
|
||||
color = Color.White,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "Pos ${ruta.posicionActual}/${ruta.totalPosiciones}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray
|
||||
)
|
||||
Text(
|
||||
text = "${ruta.combustibleConsumido}L · ${ruta.kmRecorridos}km",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Progreso
|
||||
val prog = ruta.posicionActual.toFloat() /
|
||||
ruta.totalPosiciones.toFloat()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(6.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(Color(0xFFE0E0E0))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(prog)
|
||||
.height(6.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(Color(0xFF2E7D32))
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
// Botón demo avanzar
|
||||
Button(
|
||||
onClick = { viewModel.avanzarRuta(ruta.routeId) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF0D1B2A)
|
||||
),
|
||||
contentPadding = PaddingValues(vertical = 8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.SkipNext,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text("Avanzar posición [DEMO]", fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== TOP OPERADORES =====
|
||||
if (d.topOperadores.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "🏆 Ranking de Operadores",
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = Color(0xFF0D1B2A)
|
||||
)
|
||||
}
|
||||
|
||||
items(d.topOperadores) { operador ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = when (operador.posicion) {
|
||||
1 -> Color(0xFFFFF8E1)
|
||||
2 -> Color(0xFFF5F5F5)
|
||||
3 -> Color(0xFFFBE9E7)
|
||||
else -> Color.White
|
||||
}
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(2.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = when (operador.posicion) {
|
||||
1 -> "🥇"
|
||||
2 -> "🥈"
|
||||
3 -> "🥉"
|
||||
else -> "#${operador.posicion}"
|
||||
},
|
||||
fontSize = 28.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = operador.nombre,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = "${operador.rutasCompletadas} rutas · ${operador.rutasATiempo} a tiempo",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = "${operador.puntosTotales}",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp,
|
||||
color = Color(0xFF2E7D32)
|
||||
)
|
||||
Text(
|
||||
text = "pts",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Padding final
|
||||
item { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
}
|
||||
|
||||
// Sin datos
|
||||
if (dashboard == null && !isLoading) {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text("📡", fontSize = 48.sp)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Sin datos disponibles",
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Button(onClick = { viewModel.cargarDashboard() }) {
|
||||
Text("Reintentar")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KpiCard(
|
||||
modifier: Modifier = Modifier,
|
||||
valor: String,
|
||||
label: String,
|
||||
icon: ImageVector,
|
||||
color: Color
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White),
|
||||
elevation = CardDefaults.cardElevation(2.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = color,
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = valor,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 22.sp,
|
||||
color = color
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.itc.recolector.ui.admin
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.itc.recolector.data.remote.api.AdminApi
|
||||
import com.itc.recolector.data.remote.dto.response.DashboardAdminResponse
|
||||
import com.itc.recolector.data.remote.dto.response.RutaStatusResponse
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AdminViewModel(
|
||||
private val api: AdminApi
|
||||
) : ViewModel() {
|
||||
|
||||
private val _dashboard = MutableStateFlow<DashboardAdminResponse?>(null)
|
||||
val dashboard: StateFlow<DashboardAdminResponse?> = _dashboard.asStateFlow()
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _error.asStateFlow()
|
||||
|
||||
private val _alertasFraude = MutableStateFlow<List<RutaStatusResponse>>(emptyList())
|
||||
val alertasFraude: StateFlow<List<RutaStatusResponse>> = _alertasFraude.asStateFlow()
|
||||
|
||||
init {
|
||||
cargarDashboard()
|
||||
}
|
||||
|
||||
fun cargarDashboard() {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
_error.value = null
|
||||
try {
|
||||
val response = api.dashboard()
|
||||
if (response.success && response.data != null) {
|
||||
_dashboard.value = response.data
|
||||
detectarAlertas(response.data)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Error cargando dashboard: ${e.message}"
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun avanzarRuta(routeId: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
api.avanzarRuta(routeId)
|
||||
cargarDashboard()
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Error: ${e.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reiniciarRuta(routeId: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
api.reiniciarRuta(routeId)
|
||||
cargarDashboard()
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Error: ${e.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detectar rutas sospechosas (combustible alto o sin movimiento)
|
||||
private fun detectarAlertas(data: DashboardAdminResponse) {
|
||||
val alertas = data.rutasEnCurso.filter { ruta ->
|
||||
val combustibleAlto = (ruta.combustibleConsumido ?: 0.0) > 15.0
|
||||
val sinMovimiento = ruta.status == "PAUSADO"
|
||||
combustibleAlto || sinMovimiento
|
||||
}
|
||||
_alertasFraude.value = alertas
|
||||
}
|
||||
|
||||
fun limpiarError() { _error.value = null }
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.itc.recolector.ui.auth
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.gson.Gson
|
||||
import com.itc.recolector.data.local.TokenDataStore
|
||||
import com.itc.recolector.data.remote.api.AuthApi
|
||||
import com.itc.recolector.data.remote.dto.request.LoginRequest
|
||||
import com.itc.recolector.data.remote.dto.request.RegisterRequest
|
||||
import com.itc.recolector.data.remote.dto.response.ApiResponse
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
|
||||
class AuthViewModel(
|
||||
private val api: AuthApi,
|
||||
private val dataStore: TokenDataStore
|
||||
) : ViewModel() {
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _error.asStateFlow()
|
||||
|
||||
private val _authSuccess = MutableStateFlow<String?>(null)
|
||||
val authSuccess: StateFlow<String?> = _authSuccess.asStateFlow()
|
||||
|
||||
// ===== LOGIN =====
|
||||
fun login(
|
||||
identifier: String,
|
||||
password: String,
|
||||
onSuccess: (rol: String) -> Unit
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
_error.value = null
|
||||
try {
|
||||
val response = api.login(LoginRequest(identifier, password))
|
||||
if (response.success && response.data != null) {
|
||||
val data = response.data
|
||||
|
||||
// Guardar en DataStore
|
||||
dataStore.saveAuth(
|
||||
accessToken = data.accessToken,
|
||||
refreshToken = data.refreshToken,
|
||||
rol = data.rol,
|
||||
nombre = data.nombre,
|
||||
email = data.email
|
||||
)
|
||||
|
||||
onSuccess(data.rol)
|
||||
} else {
|
||||
_error.value = response.message ?: response.error
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
// Parsear el cuerpo del error que manda el backend
|
||||
val errorMsg = parseHttpError(e)
|
||||
_error.value = errorMsg
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Error de conexión: ${e.message}"
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== REGISTER =====
|
||||
fun register(
|
||||
nombre: String,
|
||||
email: String?,
|
||||
password: String,
|
||||
telefono: String?,
|
||||
rol: String,
|
||||
onSuccess: (rol: String) -> Unit
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
_error.value = null
|
||||
try {
|
||||
val response = api.register(
|
||||
RegisterRequest(nombre, email, telefono, password, rol)
|
||||
)
|
||||
if (response.success && response.data != null) {
|
||||
val data = response.data
|
||||
|
||||
dataStore.saveAuth(
|
||||
accessToken = data.accessToken,
|
||||
refreshToken = data.refreshToken,
|
||||
rol = data.rol,
|
||||
nombre = data.nombre,
|
||||
email = data.email
|
||||
)
|
||||
|
||||
onSuccess(data.rol)
|
||||
} else {
|
||||
_error.value = response.message ?: response.error
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
val errorMsg = parseHttpError(e)
|
||||
_error.value = errorMsg
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Error de conexión: ${e.message}"
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== LOGOUT =====
|
||||
fun logout(onComplete: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val refreshToken = dataStore.getRefreshToken()
|
||||
if (refreshToken != null) {
|
||||
api.logout(refreshToken)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Limpiar local aunque falle el backend
|
||||
} finally {
|
||||
dataStore.clearAll()
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== PARSEAR ERROR HTTP =====
|
||||
private fun parseHttpError(e: HttpException): String {
|
||||
return try {
|
||||
val errorBody = e.response()?.errorBody()?.string()
|
||||
if (!errorBody.isNullOrBlank()) {
|
||||
val apiError = Gson().fromJson(errorBody, ApiResponse::class.java)
|
||||
apiError?.message ?: apiError?.error ?: "Error ${e.code()}"
|
||||
} else {
|
||||
"Error ${e.code()}: ${e.message()}"
|
||||
}
|
||||
} catch (parseEx: Exception) {
|
||||
"Error ${e.code()}: ${e.message()}"
|
||||
}
|
||||
}
|
||||
|
||||
fun limpiarError() { _error.value = null }
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package com.itc.recolector.ui.auth
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Email
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
navController: NavController,
|
||||
viewModel: AuthViewModel
|
||||
) {
|
||||
var identifier by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
val isLoading by viewModel.isLoading.collectAsState()
|
||||
val error by viewModel.error.collectAsState()
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
// Mostrar error en snackbar
|
||||
LaunchedEffect(error) {
|
||||
error?.let {
|
||||
snackbarHostState.showSnackbar(it)
|
||||
viewModel.limpiarError()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) { padding ->
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color(0xFF1B5E20),
|
||||
Color(0xFF2E7D32),
|
||||
Color(0xFF388E3C)
|
||||
)
|
||||
)
|
||||
)
|
||||
.padding(padding)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.imePadding()
|
||||
.padding(horizontal = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
|
||||
// Ícono camión
|
||||
Text(
|
||||
text = "🚛",
|
||||
fontSize = 64.sp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = "Recolector",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Sistema de recolección inteligente",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White.copy(alpha = 0.8f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
// Card formulario
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color = Color.White,
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
)
|
||||
.padding(24.dp)
|
||||
) {
|
||||
Column {
|
||||
|
||||
Text(
|
||||
text = "Iniciar sesión",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color(0xFF1B5E20)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Email o teléfono
|
||||
OutlinedTextField(
|
||||
value = identifier,
|
||||
onValueChange = { identifier = it },
|
||||
label = { Text("Email o teléfono") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Email,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFF2E7D32)
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Down) }
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Password
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("Contraseña") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFF2E7D32)
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
onClick = { passwordVisible = !passwordVisible }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (passwordVisible)
|
||||
Icons.Filled.Visibility
|
||||
else
|
||||
Icons.Filled.VisibilityOff,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFF2E7D32)
|
||||
)
|
||||
}
|
||||
},
|
||||
visualTransformation = if (passwordVisible)
|
||||
VisualTransformation.None
|
||||
else
|
||||
PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
if (identifier.isNotBlank() && password.isNotBlank()) {
|
||||
viewModel.login(identifier, password) { rol ->
|
||||
navigateByRol(navController, rol)
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(28.dp))
|
||||
|
||||
// Botón login
|
||||
Button(
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
viewModel.login(identifier, password) { rol ->
|
||||
navigateByRol(navController, rol)
|
||||
}
|
||||
},
|
||||
enabled = identifier.isNotBlank()
|
||||
&& password.isNotBlank()
|
||||
&& !isLoading,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF2E7D32)
|
||||
)
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
color = Color.White,
|
||||
modifier = Modifier.size(22.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "Entrar",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Ir a registro
|
||||
TextButton(
|
||||
onClick = { navController.navigate("register") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "¿No tienes cuenta? Regístrate",
|
||||
color = Color(0xFF2E7D32)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Credenciales demo
|
||||
Text(
|
||||
text = "¿Eres operador o admin? Contacta al municipio para obtener tu acceso.",
|
||||
color = Color.White.copy(alpha = 0.6f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Navegación por rol
|
||||
fun navigateByRol(navController: NavController, rol: String) {
|
||||
val destination = when (rol) {
|
||||
"CIUDADANO" -> "ciudadano_home"
|
||||
"CAMIONERO" -> "camionero_home"
|
||||
"ADMIN" -> "admin_dashboard"
|
||||
else -> "login"
|
||||
}
|
||||
navController.navigate(destination) {
|
||||
popUpTo("login") { inclusive = true }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
package com.itc.recolector.ui.auth
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Email
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Phone
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
|
||||
@Composable
|
||||
fun RegisterScreen(
|
||||
navController: NavController,
|
||||
viewModel: AuthViewModel
|
||||
) {
|
||||
var nombre by remember { mutableStateOf("") }
|
||||
var email by remember { mutableStateOf("") }
|
||||
var telefono by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var confirmPassword by remember { mutableStateOf("") }
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
var rolSeleccionado by remember { mutableStateOf("CIUDADANO") }
|
||||
|
||||
val isLoading by viewModel.isLoading.collectAsState()
|
||||
val error by viewModel.error.collectAsState()
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
val roles = listOf("CIUDADANO", "CAMIONERO")
|
||||
|
||||
LaunchedEffect(error) {
|
||||
error?.let {
|
||||
snackbarHostState.showSnackbar(it)
|
||||
viewModel.limpiarError()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) { padding ->
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color(0xFF1B5E20),
|
||||
Color(0xFF2E7D32),
|
||||
Color(0xFF388E3C)
|
||||
)
|
||||
)
|
||||
)
|
||||
.padding(padding)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.imePadding()
|
||||
.padding(horizontal = 32.dp, vertical = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
|
||||
Text(text = "🚛", fontSize = 48.sp)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Crear cuenta",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Regístrate para recibir notificaciones",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White.copy(alpha = 0.8f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color = Color.White,
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
)
|
||||
.padding(24.dp)
|
||||
) {
|
||||
Column {
|
||||
|
||||
Text(
|
||||
text = "Datos personales",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color(0xFF1B5E20)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Nombre
|
||||
OutlinedTextField(
|
||||
value = nombre,
|
||||
onValueChange = { nombre = it },
|
||||
label = { Text("Nombre completo") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Person,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFF2E7D32)
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Down) }
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Aviso: email o teléfono requerido
|
||||
Text(
|
||||
text = "Ingresa al menos un correo o teléfono",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color(0xFF555555)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Email (opcional)
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = { email = it },
|
||||
label = { Text("Correo electrónico (opcional)") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Email,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFF2E7D32)
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Email,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Down) }
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Teléfono (opcional)
|
||||
OutlinedTextField(
|
||||
value = telefono,
|
||||
onValueChange = { telefono = it },
|
||||
label = { Text("Teléfono (opcional)") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Phone,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFF2E7D32)
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Phone,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Down) }
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Password
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("Contraseña") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFF2E7D32)
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
onClick = { passwordVisible = !passwordVisible }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (passwordVisible)
|
||||
Icons.Filled.Visibility
|
||||
else
|
||||
Icons.Filled.VisibilityOff,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFF2E7D32)
|
||||
)
|
||||
}
|
||||
},
|
||||
visualTransformation = if (passwordVisible)
|
||||
VisualTransformation.None
|
||||
else
|
||||
PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Down) }
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Confirmar password
|
||||
val passwordsNoCoinciden = confirmPassword.isNotEmpty()
|
||||
&& confirmPassword != password
|
||||
|
||||
OutlinedTextField(
|
||||
value = confirmPassword,
|
||||
onValueChange = { confirmPassword = it },
|
||||
label = { Text("Confirmar contraseña") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
tint = if (passwordsNoCoinciden)
|
||||
Color.Red else Color(0xFF2E7D32)
|
||||
)
|
||||
},
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = { focusManager.clearFocus() }
|
||||
),
|
||||
isError = passwordsNoCoinciden,
|
||||
supportingText = {
|
||||
if (passwordsNoCoinciden) {
|
||||
Text(
|
||||
text = "Las contraseñas no coinciden",
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// Botón registro
|
||||
val formValido = nombre.isNotBlank()
|
||||
&& (email.isNotBlank() || telefono.isNotBlank())
|
||||
&& password.isNotBlank()
|
||||
&& password == confirmPassword
|
||||
&& !isLoading
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
viewModel.register(
|
||||
nombre = nombre,
|
||||
email = email.ifBlank { null },
|
||||
password = password,
|
||||
telefono = telefono.ifBlank { null },
|
||||
rol = "CIUDADANO",
|
||||
onSuccess = { rol ->
|
||||
navigateByRol(navController, rol)
|
||||
}
|
||||
)
|
||||
},
|
||||
enabled = formValido,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF2E7D32)
|
||||
)
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
color = Color.White,
|
||||
modifier = Modifier.size(22.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "Crear cuenta",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
TextButton(
|
||||
onClick = { navController.popBackStack() },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "¿Ya tienes cuenta? Inicia sesión",
|
||||
color = Color(0xFF2E7D32)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,867 @@
|
||||
package com.itc.recolector.ui.camionero
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.EmojiEvents
|
||||
import androidx.compose.material.icons.filled.Logout
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import com.itc.recolector.ui.auth.AuthViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CamioneroHomeScreen(
|
||||
navController: NavController,
|
||||
viewModel: CamioneroViewModel,
|
||||
authViewModel: AuthViewModel
|
||||
) {
|
||||
val miRuta by viewModel.miRuta.collectAsState()
|
||||
val stats by viewModel.stats.collectAsState()
|
||||
val isLoading by viewModel.isLoading.collectAsState()
|
||||
val mensaje by viewModel.mensaje.collectAsState()
|
||||
val evaluacionPendiente by viewModel.evaluacionPendiente.collectAsState()
|
||||
|
||||
// Dialogs state
|
||||
var showIncidenciaDialog by remember { mutableStateOf(false) }
|
||||
var showEvaluacionDialog by remember { mutableStateOf(false) }
|
||||
var showCelebracionDialog by remember { mutableStateOf(false) }
|
||||
var puntosGanados by remember { mutableStateOf(0) }
|
||||
|
||||
// Incidencia form state
|
||||
var tipoIncidencia by remember { mutableStateOf("TRAFICO") }
|
||||
var descripcionIncidencia by remember { mutableStateOf("") }
|
||||
var dropdownExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
// Evaluacion form state
|
||||
var llegoATiempo by remember { mutableStateOf(true) }
|
||||
var tuvoIncidencia by remember { mutableStateOf(false) }
|
||||
|
||||
val tiposIncidencia = listOf(
|
||||
"TRAFICO", "FALLA_MECANICA", "ACCIDENTE",
|
||||
"RUTA_INCOMPLETA", "CAMION_NO_PASO", "OTRO"
|
||||
)
|
||||
|
||||
// Recargar al entrar a la pantalla (el token ya está guardado tras el login)
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.cargarMiRuta()
|
||||
viewModel.cargarStats()
|
||||
}
|
||||
|
||||
// Auto abrir evaluacion si pendiente
|
||||
LaunchedEffect(evaluacionPendiente) {
|
||||
if (evaluacionPendiente) showEvaluacionDialog = true
|
||||
}
|
||||
|
||||
// ===== DIALOG INCIDENCIA =====
|
||||
if (showIncidenciaDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showIncidenciaDialog = false },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFFE65100),
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = "Reportar Incidencia",
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
text = "Reportar honestamente suma +25 puntos 🏅",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color(0xFF2E7D32)
|
||||
)
|
||||
|
||||
// Dropdown tipo
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = dropdownExpanded,
|
||||
onExpandedChange = { dropdownExpanded = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = tipoIncidencia,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Tipo de incidencia") },
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(
|
||||
expanded = dropdownExpanded
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = dropdownExpanded,
|
||||
onDismissRequest = { dropdownExpanded = false }
|
||||
) {
|
||||
tiposIncidencia.forEach { tipo ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(tipo) },
|
||||
onClick = {
|
||||
tipoIncidencia = tipo
|
||||
dropdownExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = descripcionIncidencia,
|
||||
onValueChange = { descripcionIncidencia = it },
|
||||
label = { Text("Descripción") },
|
||||
placeholder = { Text("Describe brevemente la incidencia...") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
miRuta?.routeId?.let { routeId ->
|
||||
viewModel.reportarIncidencia(
|
||||
tipo = tipoIncidencia,
|
||||
descripcion = descripcionIncidencia,
|
||||
routeId = routeId,
|
||||
onSuccess = {
|
||||
showIncidenciaDialog = false
|
||||
descripcionIncidencia = ""
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFFE65100)
|
||||
)
|
||||
) {
|
||||
Text("Reportar")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showIncidenciaDialog = false }) {
|
||||
Text("Cancelar")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ===== DIALOG EVALUACION =====
|
||||
if (showEvaluacionDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {},
|
||||
icon = {
|
||||
Text("🏁", fontSize = 36.sp)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = "¡Ruta Finalizada!",
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text(
|
||||
text = "Responde estas preguntas para ganar tus puntos:",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.Gray
|
||||
)
|
||||
|
||||
// Llegó a tiempo
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (llegoATiempo)
|
||||
Color(0xFFE8F5E9) else Color(0xFFFFF3E0)
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text(
|
||||
text = "¿Completaste la ruta a tiempo?",
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = { llegoATiempo = true },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (llegoATiempo)
|
||||
Color(0xFF2E7D32) else Color(0xFFBDBDBD)
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
) { Text("✅ Sí") }
|
||||
Button(
|
||||
onClick = { llegoATiempo = false },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (!llegoATiempo)
|
||||
Color(0xFFE65100) else Color(0xFFBDBDBD)
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
) { Text("❌ No") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tuvo incidencia
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (tuvoIncidencia)
|
||||
Color(0xFFFFF3E0) else Color(0xFFE8F5E9)
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text(
|
||||
text = "¿Tuviste alguna incidencia?",
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = { tuvoIncidencia = true },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (tuvoIncidencia)
|
||||
Color(0xFFE65100) else Color(0xFFBDBDBD)
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
) { Text("⚠️ Sí") }
|
||||
Button(
|
||||
onClick = { tuvoIncidencia = false },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (!tuvoIncidencia)
|
||||
Color(0xFF2E7D32) else Color(0xFFBDBDBD)
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
) { Text("✅ No") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
val puntos = when {
|
||||
llegoATiempo && !tuvoIncidencia -> 200
|
||||
llegoATiempo && tuvoIncidencia -> 155
|
||||
!llegoATiempo && !tuvoIncidencia -> 120
|
||||
else -> 75
|
||||
}
|
||||
puntosGanados = puntos
|
||||
miRuta?.routeId?.let { routeId ->
|
||||
viewModel.enviarEvaluacion(
|
||||
routeId = routeId,
|
||||
llegoATiempo = llegoATiempo,
|
||||
tuvoIncidencia = tuvoIncidencia,
|
||||
onSuccess = {
|
||||
showEvaluacionDialog = false
|
||||
showCelebracionDialog = true
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF2E7D32)
|
||||
)
|
||||
) {
|
||||
Text("Enviar Evaluación 🚀")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ===== DIALOG CELEBRACION =====
|
||||
if (showCelebracionDialog) {
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = 1f,
|
||||
animationSpec = tween(600),
|
||||
label = "scale"
|
||||
)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { showCelebracionDialog = false },
|
||||
title = { },
|
||||
text = {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = true,
|
||||
enter = scaleIn() + fadeIn()
|
||||
) {
|
||||
Text("🏆", fontSize = 72.sp)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "¡Excelente trabajo!",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 24.sp,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
listOf(Color(0xFF1B5E20), Color(0xFF66BB6A))
|
||||
)
|
||||
)
|
||||
.padding(horizontal = 32.dp, vertical = 16.dp)
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "+$puntosGanados",
|
||||
fontSize = 48.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color(0xFFFFD700)
|
||||
)
|
||||
Text(
|
||||
text = "PUNTOS GANADOS",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 2.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = when {
|
||||
puntosGanados >= 200 -> "🌟 ¡Rendimiento perfecto! Sigues en la cima del ranking"
|
||||
puntosGanados >= 150 -> "⭐ ¡Muy bien! Sigue así para ganar badges"
|
||||
else -> "💪 ¡Buen trabajo! Mañana lo harás mejor"
|
||||
},
|
||||
textAlign = TextAlign.Center,
|
||||
color = Color.Gray,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
stats?.let { s ->
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = Color(0xFFF3E5F5)
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "${s.puntosTotales}",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp,
|
||||
color = Color(0xFF6A1B9A)
|
||||
)
|
||||
Text("Total mes", fontSize = 11.sp, color = Color.Gray)
|
||||
}
|
||||
Divider(
|
||||
modifier = Modifier
|
||||
.height(40.dp)
|
||||
.width(1.dp)
|
||||
)
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "${s.totalBadges}",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp,
|
||||
color = Color(0xFF6A1B9A)
|
||||
)
|
||||
Text("Badges", fontSize = 11.sp, color = Color.Gray)
|
||||
}
|
||||
Divider(
|
||||
modifier = Modifier
|
||||
.height(40.dp)
|
||||
.width(1.dp)
|
||||
)
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "${s.rutasATiempo}",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp,
|
||||
color = Color(0xFF6A1B9A)
|
||||
)
|
||||
Text("A tiempo", fontSize = 11.sp, color = Color.Gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = { showCelebracionDialog = false },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF2E7D32)
|
||||
)
|
||||
) {
|
||||
Text("¡Continuar! 💪", fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ===== MAIN UI =====
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
text = "Mi Panel",
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = "Operador de recolección",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.White.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color(0xFF1B5E20)
|
||||
),
|
||||
actions = {
|
||||
IconButton(onClick = {
|
||||
authViewModel.logout {
|
||||
navController.navigate("login") {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Logout,
|
||||
contentDescription = "Salir",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(color = Color(0xFF2E7D32))
|
||||
}
|
||||
return@Scaffold
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFFF1F8E9))
|
||||
.padding(padding),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
|
||||
// Stats card
|
||||
stats?.let { s ->
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
elevation = CardDefaults.cardElevation(4.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
listOf(Color(0xFF1B5E20), Color(0xFF388E3C))
|
||||
)
|
||||
)
|
||||
.padding(20.dp)
|
||||
) {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.EmojiEvents,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFFFFD700),
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = s.nombre,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
StatItem("${s.puntosTotales}", "Puntos", "🎯")
|
||||
StatItem("${s.rutasCompletadas}", "Rutas", "🚛")
|
||||
StatItem("${s.rutasATiempo}", "A tiempo", "⏱️")
|
||||
StatItem("${s.totalBadges}", "Badges", "🏅")
|
||||
}
|
||||
|
||||
if (s.badges.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
s.badges.take(3).forEach { badge ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(
|
||||
Color.White.copy(alpha = 0.2f)
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = badge,
|
||||
color = Color.White,
|
||||
fontSize = 11.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Estado de ruta
|
||||
miRuta?.let { ruta ->
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White),
|
||||
elevation = CardDefaults.cardElevation(2.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Text(
|
||||
text = "Mi Ruta Actual",
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = Color(0xFF1B5E20)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = ruta.nombre,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
when (ruta.status) {
|
||||
"EN_RUTA" -> Color(0xFF4CAF50)
|
||||
"PAUSADO" -> Color(0xFFFF9800)
|
||||
"FINALIZADO" -> Color(0xFF9E9E9E)
|
||||
else -> Color(0xFF2196F3)
|
||||
}
|
||||
)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = ruta.status,
|
||||
color = Color.Gray,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = "Pos ${ruta.posicionActual}/${ruta.totalPosiciones}",
|
||||
color = Color(0xFF2E7D32),
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Barra progreso
|
||||
val progreso = ruta.posicionActual.toFloat() /
|
||||
ruta.totalPosiciones.toFloat()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(8.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(Color(0xFFE8F5E9))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(progreso)
|
||||
.height(8.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(Color(0xFF2E7D32))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BOTONES GIGANTES
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
|
||||
// INICIAR
|
||||
if (ruta.status == "PENDIENTE") {
|
||||
Button(
|
||||
onClick = { viewModel.iniciarRuta(ruta.routeId) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(72.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF2E7D32)
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PlayArrow,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "INICIAR RUTA",
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 2.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// PAUSAR / REANUDAR
|
||||
if (ruta.status == "EN_RUTA" || ruta.status == "PAUSADO") {
|
||||
Button(
|
||||
onClick = {
|
||||
if (ruta.status == "EN_RUTA") {
|
||||
viewModel.pausarRuta(ruta.routeId)
|
||||
} else {
|
||||
viewModel.iniciarRuta(ruta.routeId)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(64.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (ruta.status == "EN_RUTA")
|
||||
Color(0xFFFF6D00) else Color(0xFF1565C0)
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (ruta.status == "EN_RUTA")
|
||||
Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = if (ruta.status == "EN_RUTA")
|
||||
"PAUSAR RUTA" else "REANUDAR RUTA",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// REPORTAR INCIDENCIA
|
||||
if (ruta.status == "EN_RUTA" || ruta.status == "PAUSADO") {
|
||||
Button(
|
||||
onClick = { showIncidenciaDialog = true },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(64.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFFE65100)
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = "REPORTAR INCIDENCIA",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// FINALIZAR
|
||||
if (ruta.status == "EN_RUTA") {
|
||||
Button(
|
||||
onClick = { showEvaluacionDialog = true },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(64.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF6A1B9A)
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Stop,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = "FINALIZAR RUTA",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sin ruta
|
||||
if (miRuta == null) {
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text("🚛", fontSize = 48.sp)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "No tienes ruta asignada hoy",
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = "Contacta al administrador",
|
||||
color = Color.Gray,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatItem(valor: String, label: String, emoji: String) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(text = emoji, fontSize = 18.sp)
|
||||
Text(
|
||||
text = valor,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
color = Color.White.copy(alpha = 0.8f),
|
||||
fontSize = 11.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// ui/camionero/CamioneroViewModel.kt
|
||||
package com.itc.recolector.ui.camionero
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.itc.recolector.data.remote.api.CamioneroApi
|
||||
import com.itc.recolector.data.remote.dto.request.EvaluacionRequest
|
||||
import com.itc.recolector.data.remote.dto.request.IncidenciaRequest
|
||||
import com.itc.recolector.data.remote.dto.response.OperadorStatsResponse
|
||||
import com.itc.recolector.data.remote.dto.response.RutaStatusResponse
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class CamioneroViewModel(
|
||||
private val api: CamioneroApi
|
||||
) : ViewModel() {
|
||||
|
||||
// ===== STATE =====
|
||||
private val _miRuta = MutableStateFlow<RutaStatusResponse?>(null)
|
||||
val miRuta: StateFlow<RutaStatusResponse?> = _miRuta.asStateFlow()
|
||||
|
||||
private val _stats = MutableStateFlow<OperadorStatsResponse?>(null)
|
||||
val stats: StateFlow<OperadorStatsResponse?> = _stats.asStateFlow()
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _error.asStateFlow()
|
||||
|
||||
private val _evaluacionPendiente = MutableStateFlow(false)
|
||||
val evaluacionPendiente: StateFlow<Boolean> = _evaluacionPendiente.asStateFlow()
|
||||
|
||||
private val _mensaje = MutableStateFlow<String?>(null)
|
||||
val mensaje: StateFlow<String?> = _mensaje.asStateFlow()
|
||||
|
||||
init {
|
||||
cargarMiRuta()
|
||||
cargarStats()
|
||||
}
|
||||
|
||||
// ===== CARGAR MI RUTA =====
|
||||
fun cargarMiRuta() {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
try {
|
||||
val response = api.miRuta()
|
||||
if (response.success && response.data != null) {
|
||||
_miRuta.value = response.data
|
||||
|
||||
// Si la ruta finalizó, mostrar evaluación
|
||||
if (response.data.status == "COMPLETADA") {
|
||||
_evaluacionPendiente.value = true
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Error cargando ruta: ${e.message}"
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== INICIAR RUTA =====
|
||||
fun iniciarRuta(routeId: String) {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
try {
|
||||
val response = api.iniciarRuta(routeId)
|
||||
if (response.success) {
|
||||
_mensaje.value = "¡Ruta iniciada! Los ciudadanos serán notificados."
|
||||
cargarMiRuta()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Error iniciando ruta: ${e.message}"
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== PAUSAR RUTA =====
|
||||
fun pausarRuta(routeId: String) {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
try {
|
||||
val response = api.pausarRuta(routeId)
|
||||
if (response.success) {
|
||||
_mensaje.value = "¡Ruta pausada!"
|
||||
cargarMiRuta()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Error pausando ruta: ${e.message}"
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== REPORTAR INCIDENCIA =====
|
||||
fun reportarIncidencia(
|
||||
tipo: String,
|
||||
descripcion: String,
|
||||
routeId: String,
|
||||
onSuccess: () -> Unit
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
try {
|
||||
val response = api.reportarIncidencia(
|
||||
IncidenciaRequest(tipo, descripcion, routeId)
|
||||
)
|
||||
if (response.success) {
|
||||
_mensaje.value = "Incidencia reportada. +25 puntos por honestidad 🏅"
|
||||
onSuccess()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Error reportando incidencia: ${e.message}"
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== ENVIAR EVALUACIÓN =====
|
||||
fun enviarEvaluacion(
|
||||
routeId: String,
|
||||
llegoATiempo: Boolean,
|
||||
tuvoIncidencia: Boolean,
|
||||
onSuccess: () -> Unit
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
try {
|
||||
val response = api.evaluarRuta(
|
||||
routeId,
|
||||
EvaluacionRequest(llegoATiempo, tuvoIncidencia)
|
||||
)
|
||||
if (response.success) {
|
||||
_evaluacionPendiente.value = false
|
||||
_mensaje.value = "¡Evaluación enviada! Puntos actualizados 🎮"
|
||||
cargarStats()
|
||||
onSuccess()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Error enviando evaluación: ${e.message}"
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== CARGAR STATS =====
|
||||
fun cargarStats() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response = api.misStats()
|
||||
if (response.success && response.data != null) {
|
||||
_stats.value = response.data
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Error cargando stats: ${e.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun limpiarMensaje() { _mensaje.value = null }
|
||||
fun limpiarError() { _error.value = null }
|
||||
}
|
||||
@@ -0,0 +1,721 @@
|
||||
package com.itc.recolector.ui.ciudadano
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Logout
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import com.itc.recolector.data.remote.dto.response.DomicilioResponse
|
||||
import com.itc.recolector.data.remote.dto.response.ETAResponse
|
||||
import com.itc.recolector.ui.auth.AuthViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CiudadanoHomeScreen(
|
||||
navController: NavController,
|
||||
viewModel: CiudadanoViewModel,
|
||||
authViewModel: AuthViewModel
|
||||
) {
|
||||
val domicilios by viewModel.domicilios.collectAsState()
|
||||
val eta by viewModel.eta.collectAsState()
|
||||
val isLoading by viewModel.isLoading.collectAsState()
|
||||
val error by viewModel.error.collectAsState()
|
||||
|
||||
// Recargar al entrar a la pantalla (el token ya está guardado tras el login)
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.cargarDomicilios()
|
||||
}
|
||||
|
||||
var domicilioSeleccionado by remember {
|
||||
mutableStateOf<DomicilioResponse?>(null)
|
||||
}
|
||||
|
||||
// Auto seleccionar primer domicilio
|
||||
if (domicilioSeleccionado == null && domicilios.isNotEmpty()) {
|
||||
domicilioSeleccionado = domicilios.first()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
text = "Recolector",
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = "Mi servicio de recolección",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.White.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color(0xFF2E7D32)
|
||||
),
|
||||
actions = {
|
||||
IconButton(onClick = {
|
||||
domicilioSeleccionado?.let {
|
||||
viewModel.obtenerETA(it.id)
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = "Actualizar",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
authViewModel.logout {
|
||||
navController.navigate("login") {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Logout,
|
||||
contentDescription = "Cerrar sesión",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFFF1F8E9))
|
||||
.padding(padding),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
|
||||
// Banner preventivo
|
||||
item {
|
||||
BannerPreventivoCard()
|
||||
}
|
||||
|
||||
// Selector de domicilio
|
||||
if (domicilios.isNotEmpty()) {
|
||||
item {
|
||||
SelectorDomicilioCard(
|
||||
domicilios = domicilios,
|
||||
seleccionado = domicilioSeleccionado,
|
||||
onSelect = { domicilio ->
|
||||
domicilioSeleccionado = domicilio
|
||||
viewModel.obtenerETA(domicilio.id)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Widget ETA principal
|
||||
item {
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(color = Color(0xFF2E7D32))
|
||||
}
|
||||
} else if (eta != null) {
|
||||
ETAWidgetCard(
|
||||
eta = eta!!,
|
||||
onCalificar = { calificacion ->
|
||||
domicilioSeleccionado?.let { dom ->
|
||||
dom.routeId?.let { rutaId ->
|
||||
viewModel.calificarServicio(
|
||||
|
||||
rutaId,
|
||||
calificacion
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
SinDomicilioCard(
|
||||
onAgregarDomicilio = {
|
||||
navController.navigate("domicilios")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Stepper de estados
|
||||
eta?.let { etaActual ->
|
||||
item {
|
||||
StepperEstadosCard(eta = etaActual)
|
||||
}
|
||||
}
|
||||
|
||||
// Guía de separación
|
||||
item {
|
||||
GuiaSeparacionCard(
|
||||
onVerGuia = {
|
||||
navController.navigate("guia_separacion")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Error
|
||||
error?.let {
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = Color(0xFFFFEBEE)
|
||||
),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFFC62828)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = it,
|
||||
color = Color(0xFFC62828)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== BANNER PREVENTIVO =====
|
||||
@Composable
|
||||
fun BannerPreventivoCard() {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = Color(0xFFFFF8E1)
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(2.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = "⚠️", fontSize = 28.sp)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "Mensajería preventiva",
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color(0xFFE65100),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "No saques la basura hasta recibir la notificación. " +
|
||||
"Evita fauna nociva y multas.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color(0xFF6D4C41)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== SELECTOR DE DOMICILIO =====
|
||||
@Composable
|
||||
fun SelectorDomicilioCard(
|
||||
domicilios: List<DomicilioResponse>,
|
||||
seleccionado: DomicilioResponse?,
|
||||
onSelect: (DomicilioResponse) -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White),
|
||||
elevation = CardDefaults.cardElevation(2.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Home,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFF2E7D32)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Mis domicilios",
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
domicilios.forEach { domicilio ->
|
||||
val isSelected = seleccionado?.id == domicilio.id
|
||||
val bgColor by animateColorAsState(
|
||||
targetValue = if (isSelected)
|
||||
Color(0xFFE8F5E9) else Color(0xFFF5F5F5),
|
||||
animationSpec = tween(300),
|
||||
label = "bg"
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(bgColor)
|
||||
.then(
|
||||
Modifier.padding(12.dp)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isSelected) Color(0xFF2E7D32)
|
||||
else Color(0xFFBDBDBD)
|
||||
)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = domicilio.alias,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (isSelected)
|
||||
Color(0xFF1B5E20) else Color.Black
|
||||
)
|
||||
Text(
|
||||
text = domicilio.calle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray
|
||||
)
|
||||
domicilio.horarioEstimado?.let {
|
||||
Text(
|
||||
text = "🕐 $it",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color(0xFF2E7D32)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!isSelected) {
|
||||
Button(
|
||||
onClick = { onSelect(domicilio) },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF2E7D32)
|
||||
),
|
||||
contentPadding = PaddingValues(
|
||||
horizontal = 12.dp, vertical = 4.dp
|
||||
),
|
||||
modifier = Modifier.height(32.dp)
|
||||
) {
|
||||
Text("Ver ETA", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== WIDGET ETA PRINCIPAL =====
|
||||
@Composable
|
||||
fun ETAWidgetCard(
|
||||
eta: ETAResponse,
|
||||
onCalificar: (Int) -> Unit
|
||||
) {
|
||||
val gradientColors = when (eta.status) {
|
||||
"FINALIZADO" -> listOf(Color(0xFF424242), Color(0xFF616161))
|
||||
"PAUSADO" -> listOf(Color(0xFFE65100), Color(0xFFFF6D00))
|
||||
"EN_RUTA" -> listOf(Color(0xFF1B5E20), Color(0xFF2E7D32))
|
||||
else -> listOf(Color(0xFF1565C0), Color(0xFF1976D2))
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
elevation = CardDefaults.cardElevation(8.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Brush.verticalGradient(gradientColors))
|
||||
.padding(24.dp)
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
// Ícono estado
|
||||
Text(
|
||||
text = when (eta.status) {
|
||||
"EN_RUTA" -> "🚛"
|
||||
"FINALIZADO" -> "✅"
|
||||
"PAUSADO" -> "⏸️"
|
||||
else -> "🕐"
|
||||
},
|
||||
fontSize = 56.sp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Ruta
|
||||
Text(
|
||||
text = eta.nombreRuta,
|
||||
color = Color.White.copy(alpha = 0.8f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Mensaje principal
|
||||
Text(
|
||||
text = eta.mensaje,
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
// Ventana horaria
|
||||
if (eta.horaEstimadaInicio != null && eta.horaEstimadaFin != null) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color.White.copy(alpha = 0.2f))
|
||||
.padding(horizontal = 20.dp, vertical = 10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "${eta.horaEstimadaInicio} — ${eta.horaEstimadaFin}",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
eta.minutosAproximados?.let { minutos ->
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "~$minutos minutos",
|
||||
color = Color.White.copy(alpha = 0.9f),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Calificación si finalizó
|
||||
if (eta.status == "FINALIZADO") {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
Text(
|
||||
text = "¿Cómo fue el servicio?",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
(1..5).forEach { estrella ->
|
||||
IconButton(onClick = { onCalificar(estrella) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Star,
|
||||
contentDescription = "$estrella estrellas",
|
||||
tint = Color(0xFFFFD700),
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== STEPPER DE ESTADOS =====
|
||||
@Composable
|
||||
fun StepperEstadosCard(eta: ETAResponse) {
|
||||
val pasos = listOf(
|
||||
Triple("Salida", "🏭", 1),
|
||||
Triple("En camino", "🚛", 2),
|
||||
Triple("Cerca", "📍", 4),
|
||||
Triple("Llegó", "✅", eta.totalPosiciones)
|
||||
)
|
||||
|
||||
val progreso by animateFloatAsState(
|
||||
targetValue = eta.posicionActual.toFloat() / eta.totalPosiciones.toFloat(),
|
||||
animationSpec = tween(800),
|
||||
label = "progreso"
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White),
|
||||
elevation = CardDefaults.cardElevation(2.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
|
||||
Text(
|
||||
text = "Estado del recorrido",
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = Color(0xFF1B5E20)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = "Posición ${eta.posicionActual} de ${eta.totalPosiciones}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// Stepper visual
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
pasos.forEachIndexed { index, (label, emoji, posicion) ->
|
||||
val completado = eta.posicionActual >= posicion
|
||||
val esActual = eta.posicionActual in posicion until
|
||||
(pasos.getOrNull(index + 1)?.third ?: Int.MAX_VALUE)
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
// Círculo del paso
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
when {
|
||||
completado -> Color(0xFF2E7D32)
|
||||
esActual -> Color(0xFF81C784)
|
||||
else -> Color(0xFFE0E0E0)
|
||||
}
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(text = emoji, fontSize = 20.sp)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = if (esActual || completado)
|
||||
FontWeight.Bold else FontWeight.Normal,
|
||||
color = if (completado) Color(0xFF2E7D32)
|
||||
else Color.Gray,
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 11.sp
|
||||
)
|
||||
}
|
||||
|
||||
// Línea conectora entre pasos
|
||||
if (index < pasos.size - 1) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(0.5f)
|
||||
.height(3.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(
|
||||
if (eta.posicionActual > posicion)
|
||||
Color(0xFF2E7D32)
|
||||
else Color(0xFFE0E0E0)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Barra de progreso
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(8.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(Color(0xFFE8F5E9))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(progreso)
|
||||
.height(8.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
listOf(Color(0xFF1B5E20), Color(0xFF66BB6A))
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== GUÍA DE SEPARACIÓN =====
|
||||
@Composable
|
||||
fun GuiaSeparacionCard(onVerGuia: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White),
|
||||
elevation = CardDefaults.cardElevation(2.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = "♻️", fontSize = 36.sp)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Guía de separación",
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
Text(
|
||||
text = "Orgánicos, reciclables, sanitarios y especiales",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = onVerGuia,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF2E7D32)
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp)
|
||||
) {
|
||||
Text("Ver", fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== SIN DOMICILIO =====
|
||||
@Composable
|
||||
fun SinDomicilioCard(onAgregarDomicilio: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White),
|
||||
elevation = CardDefaults.cardElevation(2.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(text = "🏠", fontSize = 48.sp)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "No tienes domicilios registrados",
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = "Agrega tu domicilio para recibir notificaciones de recolección",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = onAgregarDomicilio,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF2E7D32)
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Home,
|
||||
contentDescription = null
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Agregar domicilio")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// ui/ciudadano/CiudadanoViewModel.kt
|
||||
package com.itc.recolector.ui.ciudadano
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.itc.recolector.data.local.TokenDataStore
|
||||
import com.itc.recolector.data.remote.api.CiudadanoApi
|
||||
import com.itc.recolector.data.remote.dto.response.DomicilioResponse
|
||||
import com.itc.recolector.data.remote.dto.response.ETAResponse
|
||||
import com.itc.recolector.data.remote.dto.request.DomicilioRequest
|
||||
import com.itc.recolector.service.RecolectaFirebaseService
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class CiudadanoViewModel(
|
||||
private val api: CiudadanoApi,
|
||||
private val dataStore: TokenDataStore
|
||||
) : ViewModel() {
|
||||
|
||||
// ===== STATE =====
|
||||
private val _domicilios = MutableStateFlow<List<DomicilioResponse>>(emptyList())
|
||||
val domicilios: StateFlow<List<DomicilioResponse>> = _domicilios.asStateFlow()
|
||||
|
||||
private val _eta = MutableStateFlow<ETAResponse?>(null)
|
||||
val eta: StateFlow<ETAResponse?> = _eta.asStateFlow()
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _error.asStateFlow()
|
||||
|
||||
private val _domicilioSeleccionado = MutableStateFlow<Long?>(null)
|
||||
|
||||
init {
|
||||
cargarDomicilios()
|
||||
escucharPushFCM()
|
||||
}
|
||||
|
||||
// ===== ESCUCHAR PUSH FCM → DISPARAR GET =====
|
||||
private fun escucharPushFCM() {
|
||||
viewModelScope.launch {
|
||||
RecolectaFirebaseService.etaTrigger.collect { routeId ->
|
||||
// Push llegó → buscar domicilio que corresponde a esa ruta
|
||||
val domicilio = _domicilios.value
|
||||
.firstOrNull { it.routeId == routeId }
|
||||
|
||||
domicilio?.let {
|
||||
obtenerETA(it.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== CARGAR DOMICILIOS =====
|
||||
fun cargarDomicilios() {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
_error.value = null
|
||||
try {
|
||||
val response = api.listarDomicilios()
|
||||
if (response.success && response.data != null) {
|
||||
_domicilios.value = response.data
|
||||
|
||||
// Auto-cargar ETA del primer domicilio
|
||||
response.data.firstOrNull()?.let {
|
||||
_domicilioSeleccionado.value = it.id
|
||||
obtenerETA(it.id)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Error cargando domicilios: ${e.message}"
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== OBTENER ETA (GET manual o por push) =====
|
||||
fun obtenerETA(domicilioId: Long) {
|
||||
viewModelScope.launch {
|
||||
_error.value = null
|
||||
try {
|
||||
val response = api.obtenerETA(domicilioId)
|
||||
if (response.success && response.data != null) {
|
||||
_eta.value = response.data
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Error obteniendo ETA: ${e.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== REGISTRAR DOMICILIO =====
|
||||
fun registrarDomicilio(
|
||||
alias: String,
|
||||
calle: String,
|
||||
colonia: String,
|
||||
codigoPostal: String?,
|
||||
onSuccess: () -> Unit,
|
||||
onError: (String) -> Unit
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
try {
|
||||
val response = api.registrarDomicilio(
|
||||
DomicilioRequest(alias, calle, colonia, codigoPostal)
|
||||
)
|
||||
if (response.success) {
|
||||
cargarDomicilios()
|
||||
onSuccess()
|
||||
} else {
|
||||
_error.value = response.message ?: response.error
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Error guardando domicilio: ${e.message}"
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== ELIMINAR DOMICILIO =====
|
||||
fun eliminarDomicilio(id: Long) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response = api.eliminarDomicilio(id)
|
||||
if (response.success) {
|
||||
cargarDomicilios()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Error eliminando domicilio: ${e.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== CALIFICAR SERVICIO =====
|
||||
fun calificarServicio(rutaId: String, calificacion: Int) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
api.calificarServicio(rutaId, mapOf("calificacion" to calificacion))
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Error enviando calificación: ${e.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun limpiarError() { _error.value = null }
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package com.itc.recolector.ui.ciudadano
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import com.itc.recolector.data.remote.dto.response.DomicilioResponse
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DomicilioScreen(
|
||||
navController: NavController,
|
||||
viewModel: CiudadanoViewModel
|
||||
) {
|
||||
val domicilios by viewModel.domicilios.collectAsState()
|
||||
val isLoading by viewModel.isLoading.collectAsState()
|
||||
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
var alias by remember { mutableStateOf("") }
|
||||
var calle by remember { mutableStateOf("") }
|
||||
var colonia by remember { mutableStateOf("") }
|
||||
var codigoPostal by remember { mutableStateOf("") }
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val error by viewModel.error.collectAsState()
|
||||
|
||||
LaunchedEffect(error) {
|
||||
error?.let {
|
||||
snackbarHostState.showSnackbar(it)
|
||||
viewModel.limpiarError()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
"Mis Domicilios",
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(
|
||||
Icons.Filled.ArrowBack,
|
||||
contentDescription = "Regresar",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { viewModel.cargarDomicilios() }) {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = "Actualizar", tint = Color.White)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color(0xFF1B5E20)
|
||||
)
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = { showDialog = true },
|
||||
containerColor = Color(0xFF2E7D32)
|
||||
) {
|
||||
Icon(Icons.Filled.Add, contentDescription = "Agregar", tint = Color.White)
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
|
||||
if (isLoading && domicilios.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = Color(0xFF2E7D32))
|
||||
}
|
||||
return@Scaffold
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFFF5F5F5))
|
||||
.padding(padding),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (domicilios.isEmpty()) {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 80.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text("🏠", fontSize = 56.sp)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
"Aún no tienes domicilios registrados",
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
color = Color(0xFF37474F)
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
"Agrega un domicilio para recibir notificaciones cuando el camión se acerque",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
color = Color.Gray,
|
||||
modifier = Modifier.padding(horizontal = 24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items(domicilios, key = { it.id }) { dom ->
|
||||
DomicilioCard(
|
||||
domicilio = dom,
|
||||
onVerETA = {
|
||||
navController.navigate("eta/${dom.id}")
|
||||
},
|
||||
onEliminar = { viewModel.eliminarDomicilio(dom.id) }
|
||||
)
|
||||
}
|
||||
|
||||
item { Spacer(Modifier.height(80.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
// Dialog agregar domicilio
|
||||
if (showDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDialog = false },
|
||||
title = {
|
||||
Text("Agregar domicilio", fontWeight = FontWeight.Bold, color = Color(0xFF1B5E20))
|
||||
},
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
OutlinedTextField(
|
||||
value = alias,
|
||||
onValueChange = { alias = it },
|
||||
label = { Text("Alias (ej: Casa, Oficina)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = calle,
|
||||
onValueChange = { calle = it },
|
||||
label = { Text("Calle y número") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = colonia,
|
||||
onValueChange = { colonia = it },
|
||||
label = { Text("Colonia") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = codigoPostal,
|
||||
onValueChange = { codigoPostal = it },
|
||||
label = { Text("Código postal (opcional)") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (alias.isNotBlank() && calle.isNotBlank() && colonia.isNotBlank()) {
|
||||
viewModel.registrarDomicilio(
|
||||
alias = alias,
|
||||
calle = calle,
|
||||
colonia = colonia,
|
||||
codigoPostal = codigoPostal.ifBlank { null },
|
||||
onSuccess = {
|
||||
showDialog = false
|
||||
alias = ""; calle = ""; colonia = ""; codigoPostal = ""
|
||||
},
|
||||
onError = { /* El error ya se muestra en snackbar */ }
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2E7D32))
|
||||
) { Text("Guardar") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDialog = false }) { Text("Cancelar") }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DomicilioCard(
|
||||
domicilio: DomicilioResponse,
|
||||
onVerETA: () -> Unit,
|
||||
onEliminar: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White),
|
||||
elevation = CardDefaults.cardElevation(2.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(Color(0xFF1B5E20).copy(alpha = 0.1f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Home,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFF1B5E20),
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column {
|
||||
Text(
|
||||
domicilio.alias,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color(0xFF1B2E20)
|
||||
)
|
||||
Text(
|
||||
domicilio.calle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(onClick = onEliminar) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = "Eliminar", tint = Color(0xFFE53935))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Divider(color = Color(0xFFEEEEEE))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Column {
|
||||
Text("Colonia", style = MaterialTheme.typography.labelSmall, color = Color.Gray)
|
||||
Text(domicilio.colonia, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text("Zona de cobertura", style = MaterialTheme.typography.labelSmall, color = Color.Gray)
|
||||
Text(
|
||||
domicilio.zonaCobertura,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = Color(0xFF1B5E20)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
domicilio.horarioEstimado?.let { horario ->
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.Schedule, contentDescription = null, tint = Color(0xFF2E7D32), modifier = Modifier.size(14.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(horario, style = MaterialTheme.typography.bodySmall, color = Color(0xFF2E7D32))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Button(
|
||||
onClick = onVerETA,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2E7D32))
|
||||
) {
|
||||
Icon(Icons.Filled.NearMe, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Ver ETA del camión", fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package com.itc.recolector.ui.ciudadano
|
||||
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ETAScreen(
|
||||
navController: NavController,
|
||||
domicilioId: Long,
|
||||
viewModel: CiudadanoViewModel
|
||||
) {
|
||||
val eta by viewModel.eta.collectAsState()
|
||||
val isLoading by viewModel.isLoading.collectAsState()
|
||||
val domicilios by viewModel.domicilios.collectAsState()
|
||||
val domicilio = domicilios.firstOrNull { it.id == domicilioId }
|
||||
|
||||
// Pulse animation for the status indicator
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
|
||||
val scale by infiniteTransition.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 1.15f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(800, easing = EaseInOut),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "scale"
|
||||
)
|
||||
|
||||
LaunchedEffect(domicilioId) {
|
||||
viewModel.obtenerETA(domicilioId)
|
||||
}
|
||||
|
||||
val statusColor = when (eta?.status) {
|
||||
"EN_CAMINO" -> Color(0xFF2E7D32)
|
||||
"PROXIMAMENTE" -> Color(0xFFE65100)
|
||||
"FINALIZADA" -> Color(0xFF546E7A)
|
||||
else -> Color(0xFF1565C0) // PENDIENTE
|
||||
}
|
||||
|
||||
val statusEmoji = when (eta?.status) {
|
||||
"EN_CAMINO" -> "🚛"
|
||||
"PROXIMAMENTE" -> "⚡"
|
||||
"FINALIZADA" -> "✅"
|
||||
else -> "⏳"
|
||||
}
|
||||
|
||||
val statusLabel = when (eta?.status) {
|
||||
"EN_CAMINO" -> "En camino"
|
||||
"PROXIMAMENTE" -> "¡Casi llega!"
|
||||
"FINALIZADA" -> "Ruta finalizada"
|
||||
else -> "Pendiente"
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text("ETA del Camión", fontWeight = FontWeight.Bold, color = Color.White)
|
||||
domicilio?.let {
|
||||
Text(it.alias, style = MaterialTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.8f))
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(Icons.Filled.ArrowBack, contentDescription = "Regresar", tint = Color.White)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { viewModel.obtenerETA(domicilioId) }) {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = "Actualizar", tint = Color.White)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFF1B5E20))
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
listOf(Color(0xFF1B5E20), Color(0xFF2E7D32), Color(0xFFF5F5F5)),
|
||||
startY = 0f,
|
||||
endY = 600f
|
||||
)
|
||||
)
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(color = Color.White, modifier = Modifier.size(48.dp))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text("Obteniendo información...", color = Color.White)
|
||||
} else if (eta == null) {
|
||||
Text("🔍", fontSize = 56.sp)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
"No hay información disponible\npara este domicilio",
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Button(
|
||||
onClick = { viewModel.obtenerETA(domicilioId) },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.White)
|
||||
) {
|
||||
Text("Reintentar", color = Color(0xFF1B5E20), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
} else {
|
||||
// ===== CÍRCULO DE STATUS ANIMADO =====
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(140.dp)
|
||||
.scale(if (eta!!.status == "PROXIMAMENTE") scale else 1f)
|
||||
.clip(CircleShape)
|
||||
.background(statusColor.copy(alpha = 0.15f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(110.dp)
|
||||
.clip(CircleShape)
|
||||
.background(statusColor.copy(alpha = 0.25f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(statusEmoji, fontSize = 48.sp)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
statusLabel,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Nombre de ruta
|
||||
Text(
|
||||
eta!!.nombreRuta,
|
||||
color = Color.White.copy(alpha = 0.8f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 32.dp)
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
// ===== CARD DE DETALLES =====
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White),
|
||||
elevation = CardDefaults.cardElevation(4.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(24.dp)) {
|
||||
|
||||
// Mensaje principal
|
||||
Text(
|
||||
eta!!.mensaje,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
color = Color(0xFF1B5E20),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
// Minutos aproximados
|
||||
eta!!.minutosAproximados?.let { min ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Timer,
|
||||
contentDescription = null,
|
||||
tint = statusColor,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"$min min",
|
||||
fontSize = 36.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = statusColor
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"tiempo estimado de llegada",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
Divider(color = Color(0xFFEEEEEE))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Horario estimado
|
||||
if (eta!!.horaEstimadaInicio != null && eta!!.horaEstimadaFin != null) {
|
||||
ETADetailRow(
|
||||
icon = Icons.Filled.Schedule,
|
||||
label = "Ventana de llegada",
|
||||
value = "${eta!!.horaEstimadaInicio} - ${eta!!.horaEstimadaFin}",
|
||||
color = statusColor
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
// Progreso de ruta
|
||||
ETADetailRow(
|
||||
icon = Icons.Filled.Route,
|
||||
label = "Progreso de ruta",
|
||||
value = "Parada ${eta!!.posicionActual} de ${eta!!.totalPosiciones}",
|
||||
color = Color(0xFF546E7A)
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// Barra de progreso
|
||||
val progress = eta!!.posicionActual.toFloat() / eta!!.totalPosiciones.toFloat()
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text("Inicio", style = MaterialTheme.typography.labelSmall, color = Color.Gray)
|
||||
Text("${(progress * 100).toInt()}%", style = MaterialTheme.typography.labelSmall, color = statusColor, fontWeight = FontWeight.Bold)
|
||||
Text("Fin", style = MaterialTheme.typography.labelSmall, color = Color.Gray)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { progress },
|
||||
modifier = Modifier.fillMaxWidth().height(8.dp).clip(RoundedCornerShape(4.dp)),
|
||||
color = statusColor,
|
||||
trackColor = Color(0xFFE0E0E0)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
// Botón actualizar
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.obtenerETA(domicilioId) },
|
||||
modifier = Modifier.padding(horizontal = 20.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.5f))
|
||||
) {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Actualizar ETA", fontWeight = FontWeight.Medium)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(40.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ETADetailRow(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
label: String,
|
||||
value: String,
|
||||
color: Color
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(icon, contentDescription = null, tint = color, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column {
|
||||
Text(label, style = MaterialTheme.typography.labelSmall, color = Color.Gray)
|
||||
Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package com.itc.recolector.ui.ciudadano
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GuiaSeparacionScreen(navController: NavController) {
|
||||
val categorias = listOf(
|
||||
Triple("🟢 Orgánicos", Color(0xFF2E7D32), listOf(
|
||||
"Restos de comida y cáscaras",
|
||||
"Hojas y pasto del jardín",
|
||||
"Café molido y bolsas de té",
|
||||
"Cáscaras de huevo",
|
||||
"Servilletas y papel sucias"
|
||||
)),
|
||||
Triple("🔵 Reciclables", Color(0xFF1565C0), listOf(
|
||||
"Botellas de PET (agua, refresco)",
|
||||
"Latas de aluminio y hojalata",
|
||||
"Cartón y papel limpio",
|
||||
"Vidrio (botellas, frascos)",
|
||||
"Plástico rígido (envases)"
|
||||
)),
|
||||
Triple("⚫ Sanitarios", Color(0xFF37474F), listOf(
|
||||
"Pañales y toallas sanitarias",
|
||||
"Papel higiénico usado",
|
||||
"Colillas de cigarros",
|
||||
"Chicles y envolturas sucias",
|
||||
"Algodón y curitas usados"
|
||||
)),
|
||||
Triple("🔴 Especiales", Color(0xFFC62828), listOf(
|
||||
"Pilas y baterías → Centro de acopio",
|
||||
"Medicamentos caducados → Farmacia",
|
||||
"Aceite vegetal usado → Contenedor especial",
|
||||
"Electrónicos → RAEE autorizado",
|
||||
"Pintura y solventes → No tirar al drenaje"
|
||||
))
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
"Guía de Separación",
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(Icons.Filled.ArrowBack, contentDescription = "Regresar", tint = Color.White)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFF1B5E20))
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFFF5F5F5))
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Encabezado
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFF1B5E20))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text("♻️", fontSize = 40.sp)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Separación correcta de residuos",
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
Text(
|
||||
"Separar bien ayuda al medio ambiente y mejora la eficiencia de la recolección",
|
||||
color = Color.White.copy(alpha = 0.8f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Tarjetas por categoría
|
||||
categorias.forEach { (titulo, color, items) ->
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White),
|
||||
elevation = CardDefaults.cardElevation(2.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(color)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
titulo,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = color,
|
||||
fontSize = 15.sp
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
items.forEach { item ->
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 3.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Text("•", color = color, fontWeight = FontWeight.Bold, modifier = Modifier.padding(end = 8.dp, top = 1.dp))
|
||||
Text(item, style = MaterialTheme.typography.bodySmall, color = Color(0xFF37474F))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Consejo final
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFFFFF8E1))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("💡", fontSize = 24.sp)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
"Espera la notificación de Recolector antes de sacar tu basura para evitar fauna nociva y multas.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color(0xFF6D4C41)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.itc.recolector.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import com.itc.recolector.data.local.TokenDataStore
|
||||
import com.itc.recolector.ui.admin.AdminDashboardScreen
|
||||
import com.itc.recolector.ui.admin.AdminViewModel
|
||||
import com.itc.recolector.ui.auth.AuthViewModel
|
||||
import com.itc.recolector.ui.auth.LoginScreen
|
||||
import com.itc.recolector.ui.auth.RegisterScreen
|
||||
import com.itc.recolector.ui.camionero.CamioneroHomeScreen
|
||||
import com.itc.recolector.ui.camionero.CamioneroViewModel
|
||||
import com.itc.recolector.ui.ciudadano.CiudadanoHomeScreen
|
||||
import com.itc.recolector.ui.ciudadano.CiudadanoViewModel
|
||||
import com.itc.recolector.ui.ciudadano.DomicilioScreen
|
||||
import com.itc.recolector.ui.ciudadano.ETAScreen
|
||||
import com.itc.recolector.ui.ciudadano.GuiaSeparacionScreen
|
||||
|
||||
@Composable
|
||||
fun AppNavigation(
|
||||
navController: NavHostController,
|
||||
dataStore: TokenDataStore,
|
||||
authViewModel: AuthViewModel,
|
||||
ciudadanoViewModel: CiudadanoViewModel,
|
||||
camioneroViewModel: CamioneroViewModel,
|
||||
adminViewModel: AdminViewModel
|
||||
) {
|
||||
// Determinar start destination según token y rol guardado
|
||||
val isLoggedIn by dataStore.isLoggedIn().collectAsState(initial = false)
|
||||
val rol by dataStore.getRolFlow().collectAsState(initial = null)
|
||||
|
||||
val startDestination = when {
|
||||
!isLoggedIn -> "login"
|
||||
rol == "ADMIN" -> "admin_dashboard"
|
||||
rol == "CAMIONERO" -> "camionero_home"
|
||||
rol == "CIUDADANO" -> "ciudadano_home"
|
||||
else -> "login"
|
||||
}
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination
|
||||
) {
|
||||
composable("login") {
|
||||
LoginScreen(
|
||||
navController = navController,
|
||||
viewModel = authViewModel
|
||||
)
|
||||
}
|
||||
|
||||
composable("register") {
|
||||
RegisterScreen(
|
||||
navController = navController,
|
||||
viewModel = authViewModel
|
||||
)
|
||||
}
|
||||
|
||||
composable("ciudadano_home") {
|
||||
CiudadanoHomeScreen(
|
||||
navController = navController,
|
||||
viewModel = ciudadanoViewModel,
|
||||
authViewModel = authViewModel
|
||||
)
|
||||
}
|
||||
|
||||
composable("domicilios") {
|
||||
DomicilioScreen(
|
||||
navController = navController,
|
||||
viewModel = ciudadanoViewModel
|
||||
)
|
||||
}
|
||||
|
||||
composable("eta/{domicilioId}") { back ->
|
||||
val id = back.arguments?.getString("domicilioId")?.toLong() ?: 0L
|
||||
ETAScreen(
|
||||
navController = navController,
|
||||
domicilioId = id,
|
||||
viewModel = ciudadanoViewModel
|
||||
)
|
||||
}
|
||||
|
||||
composable("guia_separacion") {
|
||||
GuiaSeparacionScreen(navController = navController)
|
||||
}
|
||||
|
||||
composable("camionero_home") {
|
||||
CamioneroHomeScreen(
|
||||
navController = navController,
|
||||
viewModel = camioneroViewModel,
|
||||
authViewModel = authViewModel
|
||||
)
|
||||
}
|
||||
|
||||
composable("admin_dashboard") {
|
||||
AdminDashboardScreen(
|
||||
navController = navController,
|
||||
viewModel = adminViewModel,
|
||||
authViewModel = authViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.itc.recolector.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.itc.recolector.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40
|
||||
|
||||
/* Other default colors to override
|
||||
background = Color(0xFFFFFBFE),
|
||||
surface = Color(0xFFFFFBFE),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onTertiary = Color.White,
|
||||
onBackground = Color(0xFF1C1B1F),
|
||||
onSurface = Color(0xFF1C1B1F),
|
||||
*/
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun RecolectorTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
// Dynamic color is available on Android 12+
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.itc.recolector.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
/* Other default text styles to override
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
*/
|
||||
)
|
||||
170
Recolector/app/src/main/res/drawable/ic_launcher_background.xml
Normal file
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
@@ -0,0 +1,30 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
BIN
Recolector/app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
Recolector/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
Recolector/app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 982 B |
BIN
Recolector/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
Recolector/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
Recolector/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
Recolector/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
Recolector/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
Recolector/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
10
Recolector/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
3
Recolector/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">Recolector</string>
|
||||
</resources>
|
||||
5
Recolector/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.Recolector" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
13
Recolector/app/src/main/res/xml/backup_rules.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample backup rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/guide/topics/data/autobackup
|
||||
for details.
|
||||
Note: This file is ignored for devices older than API 31
|
||||
See https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!--
|
||||
<include domain="sharedpref" path="."/>
|
||||
<exclude domain="sharedpref" path="device.xml"/>
|
||||
-->
|
||||
</full-backup-content>
|
||||
19
Recolector/app/src/main/res/xml/data_extraction_rules.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample data extraction rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||
for details.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
</device-transfer>
|
||||
-->
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.itc.recolector
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||