From 0adbaa14def163de21325ff5373a8d2c66d849e2 Mon Sep 17 00:00:00 2001 From: waldito Date: Sat, 23 May 2026 09:52:09 -0600 Subject: [PATCH] comit inicial --- Recolector/.gitignore | 15 + Recolector/.idea/.gitignore | 3 + Recolector/.idea/AndroidProjectSystem.xml | 6 + Recolector/.idea/compiler.xml | 6 + Recolector/.idea/deploymentTargetSelector.xml | 11 + Recolector/.idea/gradle.xml | 18 + Recolector/.idea/misc.xml | 9 + Recolector/.idea/runConfigurations.xml | 17 + Recolector/.idea/vcs.xml | 6 + Recolector/app/.gitignore | 1 + Recolector/app/build.gradle.kts | 79 ++ Recolector/app/google-services.json | 29 + Recolector/app/proguard-rules.pro | 21 + .../itc/recolector/ExampleInstrumentedTest.kt | 24 + Recolector/app/src/main/AndroidManifest.xml | 35 + .../java/com/itc/recolector/MainActivity.kt | 78 ++ .../recolector/data/local/TokenDataStore.kt | 77 ++ .../recolector/data/remote/api/AdminApi.kt | 55 ++ .../itc/recolector/data/remote/api/AuthApi.kt | 32 + .../data/remote/api/CamioneroApi.kt | 45 + .../data/remote/api/CiudadanoApi.kt | 53 ++ .../remote/dto/request/DomicilioRequest.kt | 8 + .../remote/dto/request/EvaluacionRequest.kt | 6 + .../remote/dto/request/IncidenciaRequest.kt | 7 + .../data/remote/dto/request/LoginRequest.kt | 6 + .../remote/dto/request/RegisterRequest.kt | 9 + .../data/remote/dto/response/ApiResponse.kt | 9 + .../data/remote/dto/response/AuthResponse.kt | 9 + .../data/remote/dto/response/BadgeResponse.kt | 8 + .../dto/response/DashboardAdminResponse.kt | 11 + .../remote/dto/response/DomicilioResponse.kt | 14 + .../data/remote/dto/response/ETAResponse.kt | 13 + .../dto/response/NotificacionResponse.kt | 9 + .../dto/response/OperadorStatsResponse.kt | 15 + .../dto/response/RankingOperadorResponse.kt | 12 + .../remote/dto/response/RutaStatusResponse.kt | 12 + .../com/itc/recolector/di/NetworkModule.kt | 58 ++ .../service/RecolectaFirebaseService.kt | 60 ++ .../ui/admin/AdminDashboardScreen.kt | 570 ++++++++++++ .../itc/recolector/ui/admin/AdminViewModel.kt | 84 ++ .../itc/recolector/ui/auth/AuthViewModel.kt | 143 +++ .../com/itc/recolector/ui/auth/LoginScreen.kt | 307 +++++++ .../itc/recolector/ui/auth/RegisterScreen.kt | 395 ++++++++ .../ui/camionero/CamioneroHomeScreen.kt | 867 ++++++++++++++++++ .../ui/camionero/CamioneroViewModel.kt | 169 ++++ .../ui/ciudadano/CiudadanoHomeScreen.kt | 721 +++++++++++++++ .../ui/ciudadano/CiudadanoViewModel.kt | 149 +++ .../ui/ciudadano/DomicilioScreen.kt | 314 +++++++ .../itc/recolector/ui/ciudadano/ETAScreen.kt | 316 +++++++ .../ui/ciudadano/GuiaSeparacionScreen.kt | 178 ++++ .../recolector/ui/navigation/AppNavigation.kt | 106 +++ .../java/com/itc/recolector/ui/theme/Color.kt | 11 + .../java/com/itc/recolector/ui/theme/Theme.kt | 58 ++ .../java/com/itc/recolector/ui/theme/Type.kt | 34 + .../res/drawable/ic_launcher_background.xml | 170 ++++ .../res/drawable/ic_launcher_foreground.xml | 30 + .../main/res/mipmap-anydpi/ic_launcher.xml | 6 + .../res/mipmap-anydpi/ic_launcher_round.xml | 6 + .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2898 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1772 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes Recolector/app/src/main/res/values/colors.xml | 10 + .../app/src/main/res/values/strings.xml | 3 + Recolector/app/src/main/res/values/themes.xml | 5 + .../app/src/main/res/xml/backup_rules.xml | 13 + .../main/res/xml/data_extraction_rules.xml | 19 + .../com/itc/recolector/ExampleUnitTest.kt | 17 + Recolector/build.gradle.kts | 6 + Recolector/gradle.properties | 15 + .../gradle/gradle-daemon-jvm.properties | 12 + Recolector/gradle/libs.versions.toml | 31 + Recolector/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes .../gradle/wrapper/gradle-wrapper.properties | 9 + Recolector/gradlew | 251 +++++ Recolector/gradlew.bat | 94 ++ Recolector/settings.gradle.kts | 27 + 83 files changed, 6032 insertions(+) create mode 100644 Recolector/.gitignore create mode 100644 Recolector/.idea/.gitignore create mode 100644 Recolector/.idea/AndroidProjectSystem.xml create mode 100644 Recolector/.idea/compiler.xml create mode 100644 Recolector/.idea/deploymentTargetSelector.xml create mode 100644 Recolector/.idea/gradle.xml create mode 100644 Recolector/.idea/misc.xml create mode 100644 Recolector/.idea/runConfigurations.xml create mode 100644 Recolector/.idea/vcs.xml create mode 100644 Recolector/app/.gitignore create mode 100644 Recolector/app/build.gradle.kts create mode 100644 Recolector/app/google-services.json create mode 100644 Recolector/app/proguard-rules.pro create mode 100644 Recolector/app/src/androidTest/java/com/itc/recolector/ExampleInstrumentedTest.kt create mode 100644 Recolector/app/src/main/AndroidManifest.xml create mode 100644 Recolector/app/src/main/java/com/itc/recolector/MainActivity.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/data/local/TokenDataStore.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/data/remote/api/AdminApi.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/data/remote/api/AuthApi.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/data/remote/api/CamioneroApi.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/data/remote/api/CiudadanoApi.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/request/DomicilioRequest.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/request/EvaluacionRequest.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/request/IncidenciaRequest.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/request/LoginRequest.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/request/RegisterRequest.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/ApiResponse.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/AuthResponse.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/BadgeResponse.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/DashboardAdminResponse.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/DomicilioResponse.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/ETAResponse.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/NotificacionResponse.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/OperadorStatsResponse.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/RankingOperadorResponse.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/data/remote/dto/response/RutaStatusResponse.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/di/NetworkModule.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/service/RecolectaFirebaseService.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/ui/admin/AdminDashboardScreen.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/ui/admin/AdminViewModel.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/ui/auth/AuthViewModel.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/ui/auth/LoginScreen.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/ui/auth/RegisterScreen.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/ui/camionero/CamioneroHomeScreen.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/ui/camionero/CamioneroViewModel.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/ui/ciudadano/CiudadanoHomeScreen.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/ui/ciudadano/CiudadanoViewModel.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/ui/ciudadano/DomicilioScreen.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/ui/ciudadano/ETAScreen.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/ui/ciudadano/GuiaSeparacionScreen.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/ui/navigation/AppNavigation.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/ui/theme/Color.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/ui/theme/Theme.kt create mode 100644 Recolector/app/src/main/java/com/itc/recolector/ui/theme/Type.kt create mode 100644 Recolector/app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 Recolector/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 Recolector/app/src/main/res/mipmap-anydpi/ic_launcher.xml create mode 100644 Recolector/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml create mode 100644 Recolector/app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 Recolector/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 Recolector/app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 Recolector/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 Recolector/app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 Recolector/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 Recolector/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 Recolector/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 Recolector/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 Recolector/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 Recolector/app/src/main/res/values/colors.xml create mode 100644 Recolector/app/src/main/res/values/strings.xml create mode 100644 Recolector/app/src/main/res/values/themes.xml create mode 100644 Recolector/app/src/main/res/xml/backup_rules.xml create mode 100644 Recolector/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 Recolector/app/src/test/java/com/itc/recolector/ExampleUnitTest.kt create mode 100644 Recolector/build.gradle.kts create mode 100644 Recolector/gradle.properties create mode 100644 Recolector/gradle/gradle-daemon-jvm.properties create mode 100644 Recolector/gradle/libs.versions.toml create mode 100644 Recolector/gradle/wrapper/gradle-wrapper.jar create mode 100644 Recolector/gradle/wrapper/gradle-wrapper.properties create mode 100644 Recolector/gradlew create mode 100644 Recolector/gradlew.bat create mode 100644 Recolector/settings.gradle.kts 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 0000000000000000000000000000000000000000..c209e78ecd372343283f4157dcfd918ec5165bb3 GIT binary patch literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cme5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b2dfe3d1ba5cf3ee31b3ecc1ced89044a1f3b7a9 GIT binary patch literal 2898 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4f0f1d64e58ba64d180ce43ee13bf9a17835fbca GIT binary patch literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuHB%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}

C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^QX7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!TQj4N+cqN`nQhxvX7dAV-`K|Ub$-q+H-5I?Tx0g9jWxd@A|?POE8`3b8fO$T))xP* z(X?&brZw({`)WU&rdAs1iTa0x6F@PIxJ&&L|dpySV!ID|iUhjCcKz(@mE z!x@~W#3H<)4Ae(4eQJRk`Iz3<1)6^m)0b_4_TRZ+cz#eD3f8V;2r-1fE!F}W zEi0MEkTTx}8i1{`l_6vo0(Vuh0HD$I4SjZ=?^?k82R51bC)2D_{y8mi_?X^=U?2|F{Vr7s!k(AZC$O#ZMyavHhlQ7 zUR~QXuH~#o#>(b$u4?s~HLF*3IcF7023AlwAYudn0FV~|odGH^05AYPEfR)8p`i{n zwg3zPVp{+wOsxKc>)(pMupKF!Y2HoUqQ3|Yu|8lwR=?5zZuhG6J?H`bSNk_wPoM{u zSL{c@pY7+c2kck>`^q1^^gR0QB7Y?KUD{vz-uVX~;V-rW)PDcI)$_UjgVV?S?=oLR zf4}zz{#*R_{LkiJ#0RdQLNC^2Vp%JPEUvG9ra2BVZ92(p9h7Ka@!yf9(lj#}>+|u* z;^_?KWdzkM`6gqPo9;;r6&JEa)}R3X{(CWv?NvgLeOTq$cZXqf7|sPImi-7cS8DCN zGf;DVt3Am`>hH3{4-WzH43Ftx)SofNe^-#|0HdCo<+8Qs!}TZP{HH8~z5n`ExcHuT zDL1m&|DVpIy=xsLO>8k92HcmfSKhflQ0H~9=^-{#!I1g(;+44xw~=* zxvNz35vfsQE)@)Zsp*6_GjYD};Squ83<_?^SbALb{a`j<0Gn%6JY!zhp=Fg}Ga2|8 z52e1WU%^L1}15Ex0fF$e@eCT(()_P zvV?CA%#Sy08_U6VPt4EtmVQraWJX` zh=N|WQ>LgrvF~R&qOfB$!%D3cGv?;Xh_z$z7k&s4N)$WYf*k=|*jCEkO19{h_(%W4 zPuOqbCw`SeAX*R}UUsbVsgtuG?xs(#Ikx9`JZoQFz0n*7ZG@Fv@kZk`gzO$HoA9kN z8U5{-yY zvV{`&WKU2$mZeoBmiJrEdzUZAv1sRxpePdg1)F*X^Y)zp^Y*R;;z~vOv-z&)&G)JQ{m!C9cmziu1^nHA z`#`0c>@PnQ9CJKgC5NjJD8HM3|KC(g5nnCq$n0Gsu_DXk36@ql%npEye|?%RmG)

FJ$wK}0tWNB{uH;AM~i literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..948a3070fe34c611c42c0d3ad3013a0dce358be0 GIT binary patch literal 1900 zcmV-y2b1_xNk&Fw2LJ$9MM6+kP&il$0000G0001A003VA06|PpNH75a00DqwTbm-~ zullQTcXxO9ki!OCRx^i?oR|n!<8G0=kI^!JSjFi-LL*`V;ET0H2IXfU0*i>o6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f GIT binary patch literal 3918 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�(p-{x$&=IACIfoAR!za)F-^da-t3#0Dycnp zwO~NVXwXCl;jE<}>%@xz|=8fIJAB?>+E{7)|4l${4ngA3G|=r z2Dyv;VVWSgZx9Wj>qUjleGl3Ei9K4>h!(lPS%8VOG>Xu0%6VDz^O=bjJmuP7>DeUv zrbI}MlHB^^d?{zv6d=@_ZD2lg1&G7UjnVN{1}9WkaM3H~btX0GtSzB+tZ^qRgWo4m z!GmimlG$=wgXCnr6j@m<1gAL46#T~5Bnm=2{^@>|t&`9mkEPddj zAvG~@Tv~TAm2i%VW}R-g(Z0)z-Y|szHr@rk>4MAyG*Ma*7Yh#H7(!-5>DZ@8r;_dx z{prSe<>~099F8vsYd2xff7uAS%7{S)f(|@me3t2$iy&NEc7OUEchp@9A|X;;IA>8!oX+y(BKJ$EzV* znR$z;!L$s7uy@{OT~nG#B!NRraT8(X##Ho!0r_o@gg0CA-9H^;-uE&?$2$nHv_00o z%cbuUc-tCx$Uh&EZ4Nf4Zgqv)Y6>usG3>GeQnxx_Z6+PcbX-+ysbt1hQ`K1LDpOE? zrAhIZhSN9yVIAOa22gn577tbc&i3|3V8NWy&!tw##`}9*x}gtI^h1DzZRA>UuaJG) zaZ7j)dq!O}{?#8Y7~7i6fHh4{`pL?>-18|p!S75Y#^DM>-S3)vuZG+Q7l@ek zQP~#cBpWgg#mApc_sPYjpw8odQuRokmTkzcNl`^CcKB7e&;zViV;{Y{o^Y$%7i0m# z62%#1Lq!RC?}lK>%mp}T!3Xv;L*0v*>USLm``N%>w>@fwC+#T&Tx2bN4w(20JB}oU zuSa6v^kXi0xPs?pbaOHnyiqq6By1EZY9OZ^^QA>{q-Hsd&m`pbQ%8121aWG-F5xf zlZ%;B{;C>X19|`^_?dVyCq>n+41w7|!tUS!{9rHlbhX=SZO5CQ^;!Du_E7*`GiR^Q w)2!4MKjfSAeNo!9>IaV6aUZ*?W>} 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

UmY2RIYtNPVDpE$%vda+HD#3m z&VuXJ{BK&Qe+rBa7eq}Q(bq|tn(RrJAk|ztj2(i{d>nmQnM?;HF2k&9sA6up5tmjl z7lySlzMbifH17-m-Lwa_F&e7nOH?ESi3#ckR3tsM+jsck3`oG!uMS}|eAwVXv>}qxwq?QY%QJ0}r@^;fhuUA9W z*BVl>TGo&N004@xSiwDUXUvp51sVmqO3m)=B55aPwf@0=e}cN+$-BdKxY`YrT_4)0 z_d10#i44Q*rFr8MC>*)v$EJvz``(pb{e&*6k+b zsMz%($|1+8hn8c2?P(l@;Rb&CsZeYoCI3?2!LqjbwPXW3z4G$Qfj=cT5Yb%vY0(AX oeb?AaKtwrnc|$|zzw9vfvn^aJJ!zd)XFXqqy0000001=f@-~a#s literal 0 HcmV?d00001 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 @@ + + + +