commit 0adbaa14def163de21325ff5373a8d2c66d849e2 Author: waldito Date: Sat May 23 09:52:09 2026 -0600 comit inicial diff --git a/Recolector/.gitignore b/Recolector/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/Recolector/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/Recolector/.idea/.gitignore b/Recolector/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/Recolector/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/Recolector/.idea/AndroidProjectSystem.xml b/Recolector/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/Recolector/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/Recolector/.idea/compiler.xml b/Recolector/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/Recolector/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Recolector/.idea/deploymentTargetSelector.xml b/Recolector/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..ca16a99 --- /dev/null +++ b/Recolector/.idea/deploymentTargetSelector.xml @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/Recolector/.idea/gradle.xml b/Recolector/.idea/gradle.xml new file mode 100644 index 0000000..02c4aa5 --- /dev/null +++ b/Recolector/.idea/gradle.xml @@ -0,0 +1,18 @@ + + + + + + + \ No newline at end of file diff --git a/Recolector/.idea/misc.xml b/Recolector/.idea/misc.xml new file mode 100644 index 0000000..b2c751a --- /dev/null +++ b/Recolector/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/Recolector/.idea/runConfigurations.xml b/Recolector/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/Recolector/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/Recolector/.idea/vcs.xml b/Recolector/.idea/vcs.xml new file mode 100644 index 0000000..6c0b863 --- /dev/null +++ b/Recolector/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Recolector/app/.gitignore b/Recolector/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/Recolector/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/Recolector/app/build.gradle.kts b/Recolector/app/build.gradle.kts new file mode 100644 index 0000000..0435514 --- /dev/null +++ b/Recolector/app/build.gradle.kts @@ -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") +} \ No newline at end of file diff --git a/Recolector/app/google-services.json b/Recolector/app/google-services.json new file mode 100644 index 0000000..63ede58 --- /dev/null +++ b/Recolector/app/google-services.json @@ -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" +} \ No newline at end of file diff --git a/Recolector/app/proguard-rules.pro b/Recolector/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/Recolector/app/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/Recolector/app/src/androidTest/java/com/itc/recolector/ExampleInstrumentedTest.kt b/Recolector/app/src/androidTest/java/com/itc/recolector/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..57fc9a4 --- /dev/null +++ b/Recolector/app/src/androidTest/java/com/itc/recolector/ExampleInstrumentedTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/Recolector/app/src/main/AndroidManifest.xml b/Recolector/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4639ae4 --- /dev/null +++ b/Recolector/app/src/main/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Recolector/app/src/main/java/com/itc/recolector/MainActivity.kt b/Recolector/app/src/main/java/com/itc/recolector/MainActivity.kt new file mode 100644 index 0000000..ea3cb5c --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/MainActivity.kt @@ -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 + ) + } + } + } + } +} \ No newline at end of file diff --git a/Recolector/app/src/main/java/com/itc/recolector/data/local/TokenDataStore.kt b/Recolector/app/src/main/java/com/itc/recolector/data/local/TokenDataStore.kt new file mode 100644 index 0000000..f0e24f3 --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/data/local/TokenDataStore.kt @@ -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 { + return context.dataStore.data.map { prefs -> + prefs[ACCESS_TOKEN] != null + } + } + + fun getRolFlow(): Flow { + 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] + } +} \ No newline at end of file diff --git a/Recolector/app/src/main/java/com/itc/recolector/data/remote/api/AdminApi.kt b/Recolector/app/src/main/java/com/itc/recolector/data/remote/api/AdminApi.kt new file mode 100644 index 0000000..e55da32 --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/data/remote/api/AdminApi.kt @@ -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 + + @GET("api/admin/rutas") + suspend fun rutas(): ApiResponse> + + @GET("api/admin/rutas/{routeId}/status") + suspend fun rutaStatus( + @Path("routeId") routeId: String + ): ApiResponse + + @GET("api/admin/rutas/{routeId}/combustible") + suspend fun combustible( + @Path("routeId") routeId: String + ): ApiResponse + + @GET("api/admin/operadores/ranking") + suspend fun ranking(): ApiResponse> + + @GET("api/admin/operadores/{id}/stats") + suspend fun operadorStats( + @Path("id") id: Long + ): ApiResponse + + @GET("api/admin/incidencias") + suspend fun incidencias(): ApiResponse> + + @GET("api/admin/incidencias/ruta/{routeId}") + suspend fun incidenciasPorRuta( + @Path("routeId") routeId: String + ): ApiResponse> + + @POST("api/admin/demo/ruta/{routeId}/avanzar") + suspend fun avanzarRuta( + @Path("routeId") routeId: String + ): ApiResponse + + @POST("api/admin/demo/ruta/{routeId}/reiniciar") + suspend fun reiniciarRuta( + @Path("routeId") routeId: String + ): ApiResponse +} diff --git a/Recolector/app/src/main/java/com/itc/recolector/data/remote/api/AuthApi.kt b/Recolector/app/src/main/java/com/itc/recolector/data/remote/api/AuthApi.kt new file mode 100644 index 0000000..58dd14a --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/data/remote/api/AuthApi.kt @@ -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 + + @POST("api/auth/login") + suspend fun login( + @Body request: LoginRequest + ): ApiResponse + + @POST("api/auth/refresh") + suspend fun refresh( + @Header("Refresh-Token") refreshToken: String + ): ApiResponse + + @POST("api/auth/logout") + suspend fun logout( + @Header("Refresh-Token") refreshToken: String + ): ApiResponse +} diff --git a/Recolector/app/src/main/java/com/itc/recolector/data/remote/api/CamioneroApi.kt b/Recolector/app/src/main/java/com/itc/recolector/data/remote/api/CamioneroApi.kt new file mode 100644 index 0000000..3f61968 --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/data/remote/api/CamioneroApi.kt @@ -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 + + @POST("api/camionero/ruta/{routeId}/iniciar") + suspend fun iniciarRuta( + @Path("routeId") routeId: String + ): ApiResponse + + @POST("api/camionero/ruta/{routeId}/pausar") + suspend fun pausarRuta( + @Path("routeId") routeId: String + ): ApiResponse + + @POST("api/camionero/incidencias") + suspend fun reportarIncidencia( + @Body request: IncidenciaRequest + ): ApiResponse + + @POST("api/camionero/evaluacion/{routeId}") + suspend fun evaluarRuta( + @Path("routeId") routeId: String, + @Body request: EvaluacionRequest + ): ApiResponse + + @GET("api/camionero/mis-stats") + suspend fun misStats(): ApiResponse + + @GET("api/camionero/mis-badges") + suspend fun misBadges(): ApiResponse> +} diff --git a/Recolector/app/src/main/java/com/itc/recolector/data/remote/api/CiudadanoApi.kt b/Recolector/app/src/main/java/com/itc/recolector/data/remote/api/CiudadanoApi.kt new file mode 100644 index 0000000..89dc901 --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/data/remote/api/CiudadanoApi.kt @@ -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 + + @GET("api/ciudadano/domicilios") + suspend fun listarDomicilios(): ApiResponse> + + @GET("api/ciudadano/domicilios/{id}") + suspend fun obtenerDomicilio( + @Path("id") id: Long + ): ApiResponse + + @DELETE("api/ciudadano/domicilios/{id}") + suspend fun eliminarDomicilio( + @Path("id") id: Long + ): ApiResponse + + @GET("api/ciudadano/eta/{domicilioId}") + suspend fun obtenerETA( + @Path("domicilioId") domicilioId: Long + ): ApiResponse + + @GET("api/ciudadano/notificaciones") + suspend fun obtenerNotificaciones(): ApiResponse> + + // 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 + ): ApiResponse + + @POST("api/ciudadano/fcm-token") + suspend fun actualizarFcmToken( + @Body body: Map + ): ApiResponse +} diff --git a/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/request/DomicilioRequest.kt b/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/request/DomicilioRequest.kt new file mode 100644 index 0000000..541acf3 --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/request/DomicilioRequest.kt @@ -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? +) diff --git a/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/request/EvaluacionRequest.kt b/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/request/EvaluacionRequest.kt new file mode 100644 index 0000000..235fcf8 --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/request/EvaluacionRequest.kt @@ -0,0 +1,6 @@ +package com.itc.recolector.data.remote.dto.request + +data class EvaluacionRequest( + val llegoATiempo: Boolean, + val tuvoIncidencia: Boolean +) diff --git a/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/request/IncidenciaRequest.kt b/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/request/IncidenciaRequest.kt new file mode 100644 index 0000000..c7ddc13 --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/request/IncidenciaRequest.kt @@ -0,0 +1,7 @@ +package com.itc.recolector.data.remote.dto.request + +data class IncidenciaRequest( + val tipo: String, + val descripcion: String, + val routeId: String +) diff --git a/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/request/LoginRequest.kt b/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/request/LoginRequest.kt new file mode 100644 index 0000000..87c27a5 --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/request/LoginRequest.kt @@ -0,0 +1,6 @@ +package com.itc.recolector.data.remote.dto.request + +data class LoginRequest( + val identifier: String, + val password: String +) diff --git a/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/request/RegisterRequest.kt b/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/request/RegisterRequest.kt new file mode 100644 index 0000000..4b296bd --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/request/RegisterRequest.kt @@ -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 +) diff --git a/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/ApiResponse.kt b/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/ApiResponse.kt new file mode 100644 index 0000000..0381967 --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/ApiResponse.kt @@ -0,0 +1,9 @@ +package com.itc.recolector.data.remote.dto.response + +data class ApiResponse( + val success: Boolean, + val message: String, + val data: T?, + val error: String?, + val timestamp: Any? +) diff --git a/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/AuthResponse.kt b/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/AuthResponse.kt new file mode 100644 index 0000000..589b978 --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/AuthResponse.kt @@ -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? +) diff --git a/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/BadgeResponse.kt b/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/BadgeResponse.kt new file mode 100644 index 0000000..0bbee8f --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/BadgeResponse.kt @@ -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 +) diff --git a/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/DashboardAdminResponse.kt b/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/DashboardAdminResponse.kt new file mode 100644 index 0000000..4bdc031 --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/DashboardAdminResponse.kt @@ -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, + val topOperadores: List +) diff --git a/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/DomicilioResponse.kt b/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/DomicilioResponse.kt new file mode 100644 index 0000000..e3d5503 --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/DomicilioResponse.kt @@ -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? +) diff --git a/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/ETAResponse.kt b/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/ETAResponse.kt new file mode 100644 index 0000000..7431deb --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/ETAResponse.kt @@ -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 +) diff --git a/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/NotificacionResponse.kt b/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/NotificacionResponse.kt new file mode 100644 index 0000000..2a835f9 --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/NotificacionResponse.kt @@ -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 +) diff --git a/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/OperadorStatsResponse.kt b/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/OperadorStatsResponse.kt new file mode 100644 index 0000000..29609c9 --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/OperadorStatsResponse.kt @@ -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, + val mes: Int?, + val anio: Int? +) diff --git a/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/RankingOperadorResponse.kt b/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/RankingOperadorResponse.kt new file mode 100644 index 0000000..8afb9ab --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/RankingOperadorResponse.kt @@ -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? +) diff --git a/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/RutaStatusResponse.kt b/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/RutaStatusResponse.kt new file mode 100644 index 0000000..d07db15 --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/RutaStatusResponse.kt @@ -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? +) \ No newline at end of file diff --git a/Recolector/app/src/main/java/com/itc/recolector/di/NetworkModule.kt b/Recolector/app/src/main/java/com/itc/recolector/di/NetworkModule.kt new file mode 100644 index 0000000..cf528ae --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/di/NetworkModule.kt @@ -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) +} \ No newline at end of file diff --git a/Recolector/app/src/main/java/com/itc/recolector/service/RecolectaFirebaseService.kt b/Recolector/app/src/main/java/com/itc/recolector/service/RecolectaFirebaseService.kt new file mode 100644 index 0000000..8deb88b --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/service/RecolectaFirebaseService.kt @@ -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( + 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}") + } + } + } +} \ No newline at end of file diff --git a/Recolector/app/src/main/java/com/itc/recolector/ui/admin/AdminDashboardScreen.kt b/Recolector/app/src/main/java/com/itc/recolector/ui/admin/AdminDashboardScreen.kt new file mode 100644 index 0000000..1086217 --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/ui/admin/AdminDashboardScreen.kt @@ -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 + ) + } + } +} \ No newline at end of file diff --git a/Recolector/app/src/main/java/com/itc/recolector/ui/admin/AdminViewModel.kt b/Recolector/app/src/main/java/com/itc/recolector/ui/admin/AdminViewModel.kt new file mode 100644 index 0000000..2b51e31 --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/ui/admin/AdminViewModel.kt @@ -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(null) + val dashboard: StateFlow = _dashboard.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + private val _alertasFraude = MutableStateFlow>(emptyList()) + val alertasFraude: StateFlow> = _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 } +} \ No newline at end of file diff --git a/Recolector/app/src/main/java/com/itc/recolector/ui/auth/AuthViewModel.kt b/Recolector/app/src/main/java/com/itc/recolector/ui/auth/AuthViewModel.kt new file mode 100644 index 0000000..c97bba8 --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/ui/auth/AuthViewModel.kt @@ -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 = _isLoading.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + private val _authSuccess = MutableStateFlow(null) + val authSuccess: StateFlow = _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 } +} \ No newline at end of file diff --git a/Recolector/app/src/main/java/com/itc/recolector/ui/auth/LoginScreen.kt b/Recolector/app/src/main/java/com/itc/recolector/ui/auth/LoginScreen.kt new file mode 100644 index 0000000..9052e68 --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/ui/auth/LoginScreen.kt @@ -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 } + } +} \ No newline at end of file diff --git a/Recolector/app/src/main/java/com/itc/recolector/ui/auth/RegisterScreen.kt b/Recolector/app/src/main/java/com/itc/recolector/ui/auth/RegisterScreen.kt new file mode 100644 index 0000000..8c1a26c --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/ui/auth/RegisterScreen.kt @@ -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)) + } + } + } +} \ No newline at end of file diff --git a/Recolector/app/src/main/java/com/itc/recolector/ui/camionero/CamioneroHomeScreen.kt b/Recolector/app/src/main/java/com/itc/recolector/ui/camionero/CamioneroHomeScreen.kt new file mode 100644 index 0000000..c227761 --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/ui/camionero/CamioneroHomeScreen.kt @@ -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 + ) + } +} \ No newline at end of file diff --git a/Recolector/app/src/main/java/com/itc/recolector/ui/camionero/CamioneroViewModel.kt b/Recolector/app/src/main/java/com/itc/recolector/ui/camionero/CamioneroViewModel.kt new file mode 100644 index 0000000..16e3993 --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/ui/camionero/CamioneroViewModel.kt @@ -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(null) + val miRuta: StateFlow = _miRuta.asStateFlow() + + private val _stats = MutableStateFlow(null) + val stats: StateFlow = _stats.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + private val _evaluacionPendiente = MutableStateFlow(false) + val evaluacionPendiente: StateFlow = _evaluacionPendiente.asStateFlow() + + private val _mensaje = MutableStateFlow(null) + val mensaje: StateFlow = _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 } +} \ No newline at end of file diff --git a/Recolector/app/src/main/java/com/itc/recolector/ui/ciudadano/CiudadanoHomeScreen.kt b/Recolector/app/src/main/java/com/itc/recolector/ui/ciudadano/CiudadanoHomeScreen.kt new file mode 100644 index 0000000..7d3c0b5 --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/ui/ciudadano/CiudadanoHomeScreen.kt @@ -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(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, + 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") + } + } + } +} \ No newline at end of file diff --git a/Recolector/app/src/main/java/com/itc/recolector/ui/ciudadano/CiudadanoViewModel.kt b/Recolector/app/src/main/java/com/itc/recolector/ui/ciudadano/CiudadanoViewModel.kt new file mode 100644 index 0000000..f1bbcfd --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/ui/ciudadano/CiudadanoViewModel.kt @@ -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>(emptyList()) + val domicilios: StateFlow> = _domicilios.asStateFlow() + + private val _eta = MutableStateFlow(null) + val eta: StateFlow = _eta.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + private val _domicilioSeleccionado = MutableStateFlow(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 } +} \ No newline at end of file diff --git a/Recolector/app/src/main/java/com/itc/recolector/ui/ciudadano/DomicilioScreen.kt b/Recolector/app/src/main/java/com/itc/recolector/ui/ciudadano/DomicilioScreen.kt new file mode 100644 index 0000000..41c0530 --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/ui/ciudadano/DomicilioScreen.kt @@ -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) + } + } + } +} diff --git a/Recolector/app/src/main/java/com/itc/recolector/ui/ciudadano/ETAScreen.kt b/Recolector/app/src/main/java/com/itc/recolector/ui/ciudadano/ETAScreen.kt new file mode 100644 index 0000000..ba1fb29 --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/ui/ciudadano/ETAScreen.kt @@ -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) + } + } +} diff --git a/Recolector/app/src/main/java/com/itc/recolector/ui/ciudadano/GuiaSeparacionScreen.kt b/Recolector/app/src/main/java/com/itc/recolector/ui/ciudadano/GuiaSeparacionScreen.kt new file mode 100644 index 0000000..3fc945c --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/ui/ciudadano/GuiaSeparacionScreen.kt @@ -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)) + } + } +} diff --git a/Recolector/app/src/main/java/com/itc/recolector/ui/navigation/AppNavigation.kt b/Recolector/app/src/main/java/com/itc/recolector/ui/navigation/AppNavigation.kt new file mode 100644 index 0000000..8bf7613 --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/ui/navigation/AppNavigation.kt @@ -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 + ) + } + } +} \ No newline at end of file diff --git a/Recolector/app/src/main/java/com/itc/recolector/ui/theme/Color.kt b/Recolector/app/src/main/java/com/itc/recolector/ui/theme/Color.kt new file mode 100644 index 0000000..1a9c95c --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/ui/theme/Color.kt @@ -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) \ No newline at end of file diff --git a/Recolector/app/src/main/java/com/itc/recolector/ui/theme/Theme.kt b/Recolector/app/src/main/java/com/itc/recolector/ui/theme/Theme.kt new file mode 100644 index 0000000..6905076 --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/ui/theme/Theme.kt @@ -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 + ) +} \ No newline at end of file diff --git a/Recolector/app/src/main/java/com/itc/recolector/ui/theme/Type.kt b/Recolector/app/src/main/java/com/itc/recolector/ui/theme/Type.kt new file mode 100644 index 0000000..d6168a2 --- /dev/null +++ b/Recolector/app/src/main/java/com/itc/recolector/ui/theme/Type.kt @@ -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 + ) + */ +) \ No newline at end of file diff --git a/Recolector/app/src/main/res/drawable/ic_launcher_background.xml b/Recolector/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/Recolector/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Recolector/app/src/main/res/drawable/ic_launcher_foreground.xml b/Recolector/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/Recolector/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/Recolector/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/Recolector/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/Recolector/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Recolector/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/Recolector/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/Recolector/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Recolector/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/Recolector/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/Recolector/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/Recolector/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/Recolector/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/Recolector/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/Recolector/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/Recolector/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/Recolector/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/Recolector/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/Recolector/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/Recolector/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/Recolector/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/Recolector/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/Recolector/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/Recolector/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/Recolector/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/Recolector/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/Recolector/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/Recolector/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/Recolector/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/Recolector/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/Recolector/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/Recolector/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/Recolector/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/Recolector/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/Recolector/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/Recolector/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/Recolector/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/Recolector/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/Recolector/app/src/main/res/values/colors.xml b/Recolector/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/Recolector/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/Recolector/app/src/main/res/values/strings.xml b/Recolector/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..90712bf --- /dev/null +++ b/Recolector/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Recolector + \ No newline at end of file diff --git a/Recolector/app/src/main/res/values/themes.xml b/Recolector/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..540b320 --- /dev/null +++ b/Recolector/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +