diff --git a/README.md b/README.md index b27908c..78ed668 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,90 @@ -# AppRecoleccion +# 🗑️ Celaya Limpia — Sistema Integral de Recolección de Residuos -Es una aplicación diseñada para el monitoreo y control de los camiones de basura permitiéndole al usuario saber cuando el camión de basura se encuentre cerca de su domicilio +## H. Ayuntamiento de Celaya, Guanajuato +--- -# Problemática de Aplicación Móvil +## 👥 Roles del Sistema -Sistema de Notificación Inteligente y Privada de Recolección de Residuos +### 🏠 CIUDADANO +- ETA del camión recolector en tiempo real +- **Mapa visible SOLO cuando el camión está a <15 min** +- Guía de separación de residuos (sin internet) +- Clasificador de residuos con IA (cámara) +- Reporte de incidencias -En la actualidad, la ciudadanía no cuenta con certeza sobre el momento exacto en que el camión recolector de basura pasará por su domicilio. Esto provoca un efecto dominó negativo: la basura se saca a la calle demasiado temprano, demasiado tarde o cuando la unidad ya pasó. +### 🚛 CONDUCTOR +- Vista de su ruta asignada por día +- Mapa de su ruta específica +- Alertas de GPS desactivado +- Horario semanal asignado por el admin -La falta de información oportuna genera problemas de salud pública, fauna nociva, molestias vecinales y una disposición ineficiente de los residuos. Sin embargo, la solución no es simplemente ponerle un GPS al camión y mostrarlo en un mapa público. Mostrar el recorrido completo o las rutas de otros sectores incrementa el riesgo de uso inadecuado del servicio (como personas intentando alcanzar el camión en movimiento) y expone información operativa sensible de la flotilla municipal. +### ⚙️ ADMINISTRADOR +- Mapa de TODAS las rutas simultáneamente +- Control de estado de rutas (cancelar, marcar falla, retraso) +- Gestión de reportes ciudadanos +- Asignación de rutas a conductores por día/turno +- Panel de alertas (GPS perdido, camión detenido +30min) -Adicionalmente, existe una fuerte necesidad de educación ciudadana: la gente quiere separar su basura, pero muchas veces no sabe cómo hacerlo correctamente. +--- -El Reto Desarrollar el prototipo de una aplicación móvil orientada a la ciudadanía que indique una hora aproximada de llegada del camión recolector al domicilio registrado, que notifique sobre cambios operativos y que eduque sobre la separación de residuos. +## 🔑 Cuentas Demo -Todo esto bajo un principio innegociable de Privacidad por Diseño: el usuario debe obtener la información que necesita sin acceder al mapa completo de la ruta ni rastrear el vehículo en tiempo real. +| Rol | Email | Contraseña | +|-----|-------|-----------| +| Administrador | admin@celaya.gob.mx | admin123 | +| Conductor | conductor@celaya.gob.mx | conductor123 | +| Ciudadano | Crear desde la app | - | -Requisitos Funcionales (MVP) +--- -Gestión de Identidad y Domicilios: Registro de usuario seguro (email o teléfono) y capacidad de dar de alta y validar uno o más domicilios (ejemplo: casa y trabajo). +## 🚀 Cómo ejecutar -Ventana de Llegada (ETA): La interfaz debe traducir los datos operativos en un mensaje claro y orientado a la acción: "El camión llegará a tu zona entre las 7:20 y 7:35 p.m." o "Llega en aproximadamente 15 minutos". +```bash +flutter pub get +flutter run +``` -Alertas y Notificaciones Push: Avisos configurables cuando la ruta esté por iniciar, cuando el camión se aproxime o si hay retrasos por tráfico o fallas mecánicas. +--- -Visión de "Túnel" (Ruta Exclusiva): El ciudadano solo puede ver la información de la ruta asignada a su dirección validada. Cero visibilidad de colonias vecinas. +## 🤖 IA de Clasificación de Residuos -Guía de Separación Integrada: Una sección visual, rápida y que funcione sin conexión con categorías claras (orgánicos, reciclables, sanitarios, especiales) para educar al ciudadano. +1. Convierte tu modelo: `waste_clasification.h5` → `waste_model.tflite` +2. Coloca el archivo en: `assets/models/waste_model.tflite` +3. El modelo clasifica: **Orgánico (0) / Inorgánico (1)** -Buzón de Retroalimentación: Módulo básico para reportar incidencias (ejemplo: "El camión no pasó") o calificar el servicio. +Script de conversión: +```python +import tensorflow as tf +model = tf.keras.models.load_model('waste_clasification.h5') +converter = tf.lite.TFLiteConverter.from_keras_model(model) +tflite_model = converter.convert() +with open('assets/models/waste_model.tflite', 'wb') as f: + f.write(tflite_model) +``` -Restricciones de Diseño (Reglas del Juego) +--- -PROHIBIDO el Seguidor de Ruta Activo: La app no debe mostrar un mapa con el camión moviéndose en tiempo real. +## 🗺️ Mapas -PROHIBIDO el Snooping: No se debe poder explorar las rutas de otros domicilios o usuarios. +Usa **OpenStreetMap** (gratuito, sin API Key) -Mensajería Preventiva: La interfaz debe desalentar explícitamente a los usuarios de sacar la basura fuera del horario establecido o de perseguir a la unidad. +--- -Requisitos Técnicos y Arquitectura +## 🔔 Notificaciones Simuladas -Se requiere demostrar una arquitectura de software sólida, escalable y pensada para un despliegue real. +| Evento | Cuándo | +|--------|--------| +| 🚛 Ruta Iniciada | posición 1→2 | +| ⚠️ Camión Cercano | posición 4 (~15 min) | +| 🏁 Servicio Finalizado | posición 8 | +| 📡 GPS Perdido | Admin lo activa manualmente | +| ⚠️ Camión Detenido | Sin movimiento 30+ min | -Arquitectura de Software (Backend): Se espera la implementación de una API RESTful limpia (preferentemente en frameworks modernos como Go, Python/FastAPI, Node.js o C#/.NET) que separe la lógica de negocio de la capa de datos. +--- -Simulación de Eventos (Mocking): Dado que no tendrán acceso a la telemetría real de los camiones, deberán simular el avance de la ruta en el backend mediante un trabajo programado (Cron Job), WebSockets o un script de simulación que actualice el estado y dispare las notificaciones push en tiempo real. - -Frontend Móvil: Desarrollo compatible con iOS y Android. Se recomienda encarecidamente el uso de tecnologías multiplataforma (Flutter, React Native) o Aplicaciones Web Progresivas (PWA) de alto rendimiento. - -Gestión del Estado: Manejo eficiente del estado en la aplicación para no saturar el servidor con peticiones (evitar peticiones continuas o polling excesivo). - -Base de Datos y Modelado: Modelado relacional o NoSQL que maneje correctamente la jerarquía: Usuario -> Domicilio -> Zona de Cobertura -> Ruta. Se valora indexación geoespacial básica para validar si el domicilio ingresado entra en los polígonos permitidos. - -Seguridad y Privacidad: Autenticación basada en JSON Web Tokens (JWT) o implementaciones seguras mediante OAuth. El control de acceso basado en roles (RBAC) es obligatorio: la API debe validar en cada petición que el usuario solo consulta la información del domicilio que le pertenece. - -Sugerencias de Optimización: Integración y Despliegue Eficiente (Opcional) -Para robustecer la propuesta técnica con miras a un entorno de producción real, se sugiere considerar las siguientes estrategias de integración y manejo de costos: - -Consumo Inteligente de APIs Geográficas: Se recomienda explorar cómo reducir costos al interactuar con mapas. Por ejemplo, utilizar servicios de geocodificación (como Google Maps o la alternativa libre OpenStreetMap/Nominatim) únicamente en el registro del domicilio, almacenando la coordenada para no repetir peticiones innecesarias. El cálculo de distancias y ETAs puede resolverse de forma interna en el backend con herramientas de análisis espacial (como extensiones PostGIS o librerías nativas del lenguaje) para evitar tarifas por llamadas constantes a APIs de pago. - -Estrategia en la Nube y Control de Costos: Dado que el servicio de recolección opera en horarios específicos y tiene valles de inactividad, se sugiere el diseño sobre arquitecturas elásticas o Serverless (como AWS Lambda / Cloud Run para cómputo, o Redis para caché en memoria). Esto permitiría simular o implementar un sistema que escale a cero cuando no hay camiones en ruta, minimizando el gasto de infraestructura. - -Notificaciones Asíncronas: Considerar el uso de pasarelas de mensajería eficientes (como Firebase Cloud Messaging o servicios similares de bajo costo) para enviar las alertas push, garantizando que el flujo de comunicación sea ligero y no dependa de conexiones persistentes de alto impacto para el servidor. +## 📋 Rutas disponibles +- 15 rutas con GPS real de Celaya +- Turnos: Matutino, Vespertino, Nocturno +- 40+ colonias mapeadas diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 8237bac..c9f60b8 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,13 +1,12 @@ plugins { id("com.android.application") - // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") } android { - namespace = "com.example.ejemplo" + namespace = "com.example.celaya_limpia" compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion + ndkVersion = "28.2.13676358" compileOptions { sourceCompatibility = JavaVersion.VERSION_17 @@ -15,10 +14,7 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.example.ejemplo" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. + applicationId = "com.example.celaya_limpia" minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode @@ -27,8 +23,6 @@ android { buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. signingConfig = signingConfigs.getByName("debug") } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4d3bb99..868d381 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,14 @@ + + + + + + + + - - + - - + - diff --git a/android/app/src/main/kotlin/com/example/celaya_limpia/MainActivity.kt b/android/app/src/main/kotlin/com/example/celaya_limpia/MainActivity.kt new file mode 100644 index 0000000..83f3236 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/celaya_limpia/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.celaya_limpia + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/build.gradle.kts b/android/build.gradle.kts index dbee657..c2e1c94 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -5,15 +5,11 @@ allprojects { } } -val newBuildDir: Directory = - rootProject.layout.buildDirectory - .dir("../../build") - .get() +val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get() rootProject.layout.buildDirectory.value(newBuildDir) - subprojects { val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) - project.layout.buildDirectory.value(newSubprojectBuildDir) + layout.buildDirectory.value(newSubprojectBuildDir) } subprojects { project.evaluationDependsOn(":app") @@ -22,3 +18,18 @@ subprojects { tasks.register("clean") { delete(rootProject.layout.buildDirectory) } + +// Forzar Java 17 en todos los subproyectos (plugins) +gradle.projectsEvaluated { + subprojects { + tasks.withType().configureEach { + sourceCompatibility = "17" + targetCompatibility = "17" + } + tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } + } + } +} diff --git a/assets/models/README.txt b/assets/models/README.txt new file mode 100644 index 0000000..2bbddba --- /dev/null +++ b/assets/models/README.txt @@ -0,0 +1 @@ +placeholder - add waste_model.tflite here diff --git a/assets/models/labels.txt b/assets/models/labels.txt new file mode 100644 index 0000000..476e586 --- /dev/null +++ b/assets/models/labels.txt @@ -0,0 +1,2 @@ +Orgánico +Inorgánico \ No newline at end of file diff --git a/assets/models/waste_model.tflite b/assets/models/waste_model.tflite new file mode 100644 index 0000000..24c6966 Binary files /dev/null and b/assets/models/waste_model.tflite differ diff --git a/lib/core/app_colors.dart b/lib/core/app_colors.dart new file mode 100644 index 0000000..56f3b36 --- /dev/null +++ b/lib/core/app_colors.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; + +class AppColors { + static const Color guindaPrimary = Color(0xFF6D1E3A); + static const Color guindaDark = Color(0xFF4A1228); + static const Color guindaLight = Color(0xFF9B3D5C); + static const Color dorado = Color(0xFFC9A84C); + static const Color blanco = Color(0xFFFFFFFF); + static const Color grisFondo = Color(0xFFF5F5F5); + static const Color grisTexto = Color(0xFF757575); + static const Color negroTexto = Color(0xFF212121); + static const Color verdeExito = Color(0xFF2E7D32); + static const Color rojoError = Color(0xFFC62828); + static const Color naranjaAlerta = Color(0xFFE65100); + static const Color azulInfo = Color(0xFF1565C0); + static const Color moradoConductor= Color(0xFF4A148C); + static const Color verdeAdmin = Color(0xFF1B5E20); +} + +class AppRoles { + static const String ciudadano = 'CIUDADANO'; + static const String conductor = 'CONDUCTOR'; + static const String administrador = 'ADMINISTRADOR'; +} + +class AppTurnos { + static const String matutino = 'MATUTINO'; + static const String vespertino= 'VESPERTINO'; + static const String nocturno = 'NOCTURNO'; +} + +class AppDias { + static const List todos = [ + 'LUNES','MARTES','MIERCOLES','JUEVES','VIERNES','SABADO','DOMINGO' + ]; + static String label(String dia) { + const m = { + 'LUNES':'Lunes','MARTES':'Martes','MIERCOLES':'Miércoles', + 'JUEVES':'Jueves','VIERNES':'Viernes','SABADO':'Sábado','DOMINGO':'Domingo', + }; + return m[dia] ?? dia; + } +} + +class RouteStatus { + static const String enRuta = 'EN_RUTA'; + static const String cancelada = 'CANCELADA'; + static const String retrasada = 'RETRASADA'; + static const String fallaMecanica= 'FALLA_MECANICA'; + static const String finalizada = 'FINALIZADA'; + + static Color color(String status) { + switch (status) { + case enRuta: return AppColors.verdeExito; + case cancelada: return AppColors.rojoError; + case retrasada: return AppColors.naranjaAlerta; + case fallaMecanica: return Colors.red.shade900; + case finalizada: return AppColors.grisTexto; + default: return AppColors.grisTexto; + } + } + + static String label(String status) { + switch (status) { + case enRuta: return '🚛 En Ruta'; + case cancelada: return '❌ Cancelada'; + case retrasada: return '⏱️ Retrasada'; + case fallaMecanica: return '🔧 Falla Mecánica'; + case finalizada: return '✅ Finalizada'; + default: return status; + } + } +} diff --git a/lib/data/colonies_data.dart b/lib/data/colonies_data.dart new file mode 100644 index 0000000..6d56e9f --- /dev/null +++ b/lib/data/colonies_data.dart @@ -0,0 +1,51 @@ +import '../models/route_model.dart'; + +final List coloniesData = [ + ColonyModel(colonia:'Zona Centro',routeId:'RUTA-01',horarioEstimado:'Matutino (06:30-07:15)'), + ColonyModel(colonia:'Las Arboledas',routeId:'RUTA-01',horarioEstimado:'Matutino (07:00-07:30)'), + ColonyModel(colonia:'Centro Histórico',routeId:'RUTA-01',horarioEstimado:'Matutino (06:20-07:00)'), + ColonyModel(colonia:'Barrio de Santiago',routeId:'RUTA-01',horarioEstimado:'Matutino (06:30-07:10)'), + ColonyModel(colonia:'Col. Obrera',routeId:'RUTA-01',horarioEstimado:'Matutino (06:50-07:25)'), + ColonyModel(colonia:'Av. Tecnológico',routeId:'RUTA-02',horarioEstimado:'Matutino (06:20-07:00)'), + ColonyModel(colonia:'Col. Magisterial',routeId:'RUTA-02',horarioEstimado:'Matutino (06:40-07:15)'), + ColonyModel(colonia:'Fracc. Las Américas',routeId:'RUTA-02',horarioEstimado:'Matutino (06:55-07:30)'), + ColonyModel(colonia:'Col. Constitución',routeId:'RUTA-02',horarioEstimado:'Matutino (06:30-07:05)'), + ColonyModel(colonia:'San Juanico',routeId:'RUTA-03',horarioEstimado:'Vespertino (14:45-15:15)'), + ColonyModel(colonia:'Col. Los Álamos',routeId:'RUTA-03',horarioEstimado:'Vespertino (14:30-15:00)'), + ColonyModel(colonia:'Fracc. El Dorado',routeId:'RUTA-03',horarioEstimado:'Vespertino (15:00-15:30)'), + ColonyModel(colonia:'Los Olivos',routeId:'RUTA-04',horarioEstimado:'Matutino (07:00-07:40)'), + ColonyModel(colonia:'Col. Revolución',routeId:'RUTA-04',horarioEstimado:'Matutino (06:35-07:10)'), + ColonyModel(colonia:'Col. Ladrillera',routeId:'RUTA-04',horarioEstimado:'Matutino (06:50-07:25)'), + ColonyModel(colonia:'Rancho Seco',routeId:'RUTA-05',horarioEstimado:'Vespertino (15:00-15:35)'), + ColonyModel(colonia:'Col. El Potrero',routeId:'RUTA-05',horarioEstimado:'Vespertino (14:45-15:20)'), + ColonyModel(colonia:'Col. Los Sauces',routeId:'RUTA-05',horarioEstimado:'Vespertino (15:15-15:50)'), + ColonyModel(colonia:'Rumbos de Roque',routeId:'RUTA-06',horarioEstimado:'Matutino (06:30-07:10)'), + ColonyModel(colonia:'Col. Vista Hermosa',routeId:'RUTA-06',horarioEstimado:'Matutino (06:45-07:20)'), + ColonyModel(colonia:'Ciudad Industrial',routeId:'RUTA-07',horarioEstimado:'Matutino (06:30-07:10)'), + ColonyModel(colonia:'Parque Industrial',routeId:'RUTA-07',horarioEstimado:'Matutino (06:50-07:25)'), + ColonyModel(colonia:'Universidad Latina',routeId:'RUTA-08',horarioEstimado:'Nocturno (22:30-23:00)'), + ColonyModel(colonia:'Col. Del Moral',routeId:'RUTA-08',horarioEstimado:'Nocturno (22:00-22:30)'), + ColonyModel(colonia:'Hospital General',routeId:'RUTA-09',horarioEstimado:'Matutino (06:20-07:00)'), + ColonyModel(colonia:'Col. Peñuelas',routeId:'RUTA-09',horarioEstimado:'Matutino (06:50-07:20)'), + ColonyModel(colonia:'UG Sur',routeId:'RUTA-10',horarioEstimado:'Nocturno (21:30-22:00)'), + ColonyModel(colonia:'Eje Juan Pablo II',routeId:'RUTA-10',horarioEstimado:'Nocturno (21:00-21:30)'), + ColonyModel(colonia:'Torres Landa',routeId:'RUTA-11',horarioEstimado:'Matutino (06:45-07:15)'), + ColonyModel(colonia:'Zona de Oro',routeId:'RUTA-11',horarioEstimado:'Matutino (06:30-07:00)'), + ColonyModel(colonia:'Las Insurgentes',routeId:'RUTA-12',horarioEstimado:'Matutino (06:35-07:10)'), + ColonyModel(colonia:'Col. Independencia',routeId:'RUTA-12',horarioEstimado:'Matutino (06:50-07:20)'), + ColonyModel(colonia:'Trojes',routeId:'RUTA-13',horarioEstimado:'Matutino (06:40-07:10)'), + ColonyModel(colonia:'Irrigación',routeId:'RUTA-13',horarioEstimado:'Matutino (06:55-07:25)'), + ColonyModel(colonia:'Col. Benito Juárez',routeId:'RUTA-13',horarioEstimado:'Matutino (06:30-07:00)'), + ColonyModel(colonia:'La Toscana',routeId:'RUTA-14',horarioEstimado:'Vespertino (15:00-15:35)'), + ColonyModel(colonia:'Fracc. La Laborcita',routeId:'RUTA-14',horarioEstimado:'Vespertino (14:45-15:20)'), + ColonyModel(colonia:'San José de Celaya',routeId:'RUTA-15',horarioEstimado:'Nocturno (22:45-23:20)'), + ColonyModel(colonia:'Col. Camino Real',routeId:'RUTA-15',horarioEstimado:'Nocturno (22:30-23:00)'), + ColonyModel(colonia:'Col. Jardín',routeId:'RUTA-15',horarioEstimado:'Nocturno (23:00-23:30)'), +]; + +ColonyModel? getColonyByName(String name) { + try { return coloniesData.firstWhere((c) => c.colonia.toLowerCase() == name.toLowerCase()); } + catch (_) { return null; } +} + +List get colonyNames => coloniesData.map((c) => c.colonia).toList()..sort(); diff --git a/lib/data/routes_data.dart b/lib/data/routes_data.dart new file mode 100644 index 0000000..bdfd4dd --- /dev/null +++ b/lib/data/routes_data.dart @@ -0,0 +1,159 @@ +import '../models/route_model.dart'; + +final List routesData = [ + RouteModel(routeId:'RUTA-01',name:'Zona Centro - Las Arboledas',truckId:101,status:'EN_RUTA',turno:'MATUTINO',positions:[ + RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'06:00'), + RoutePosition(positionId:2,lat:20.5185,lng:-100.8450,speed:45,timestamp:'06:12'), + RoutePosition(positionId:3,lat:20.5215,lng:-100.8142,speed:22,timestamp:'06:25'), + RoutePosition(positionId:4,lat:20.5212,lng:-100.8175,speed:15,timestamp:'06:38'), + RoutePosition(positionId:5,lat:20.5210,lng:-100.8210,speed:0,timestamp:'06:50'), + RoutePosition(positionId:6,lat:20.5235,lng:-100.8212,speed:18,timestamp:'07:05'), + RoutePosition(positionId:7,lat:20.5260,lng:-100.8215,speed:20,timestamp:'07:18'), + RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:40,timestamp:'07:40'), + ]), + RouteModel(routeId:'RUTA-02',name:'Sector Norte - Av. Tecnológico',truckId:102,status:'EN_RUTA',turno:'MATUTINO',positions:[ + RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'06:05'), + RoutePosition(positionId:2,lat:20.5280,lng:-100.8135,speed:38,timestamp:'06:18'), + RoutePosition(positionId:3,lat:20.5410,lng:-100.8130,speed:25,timestamp:'06:30'), + RoutePosition(positionId:4,lat:20.5445,lng:-100.8132,speed:12,timestamp:'06:45'), + RoutePosition(positionId:5,lat:20.5480,lng:-100.8135,speed:0,timestamp:'06:58'), + RoutePosition(positionId:6,lat:20.5515,lng:-100.8138,speed:15,timestamp:'07:10'), + RoutePosition(positionId:7,lat:20.5540,lng:-100.8110,speed:22,timestamp:'07:25'), + RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:45,timestamp:'07:50'), + ]), + RouteModel(routeId:'RUTA-03',name:'Sector Poniente - San Juanico',truckId:103,status:'EN_RUTA',turno:'VESPERTINO',positions:[ + RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'14:00'), + RoutePosition(positionId:2,lat:20.5250,lng:-100.8510,speed:42,timestamp:'14:15'), + RoutePosition(positionId:3,lat:20.5290,lng:-100.8320,speed:20,timestamp:'14:30'), + RoutePosition(positionId:4,lat:20.5315,lng:-100.8355,speed:15,timestamp:'14:45'), + RoutePosition(positionId:5,lat:20.5340,lng:-100.8390,speed:0,timestamp:'15:00'), + RoutePosition(positionId:6,lat:20.5362,lng:-100.8425,speed:10,timestamp:'15:15'), + RoutePosition(positionId:7,lat:20.5330,lng:-100.8430,speed:18,timestamp:'15:28'), + RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:35,timestamp:'15:45'), + ]), + RouteModel(routeId:'RUTA-04',name:'Oriente - Los Olivos',truckId:104,status:'EN_RUTA',turno:'MATUTINO',positions:[ + RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'06:15'), + RoutePosition(positionId:2,lat:20.5260,lng:-100.8010,speed:45,timestamp:'06:30'), + RoutePosition(positionId:3,lat:20.5295,lng:-100.7890,speed:24,timestamp:'06:45'), + RoutePosition(positionId:4,lat:20.5320,lng:-100.7850,speed:12,timestamp:'06:58'), + RoutePosition(positionId:5,lat:20.5350,lng:-100.7790,speed:0,timestamp:'07:12'), + RoutePosition(positionId:6,lat:20.5310,lng:-100.7760,speed:15,timestamp:'07:25'), + RoutePosition(positionId:7,lat:20.5270,lng:-100.7820,speed:26,timestamp:'07:38'), + RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:48,timestamp:'07:58'), + ]), + RouteModel(routeId:'RUTA-05',name:'Sector Sur - Rancho Seco',truckId:105,status:'EN_RUTA',turno:'VESPERTINO',positions:[ + RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'14:20'), + RoutePosition(positionId:2,lat:20.5050,lng:-100.8620,speed:35,timestamp:'14:32'), + RoutePosition(positionId:3,lat:20.5020,lng:-100.8350,speed:22,timestamp:'14:45'), + RoutePosition(positionId:4,lat:20.4995,lng:-100.8210,speed:14,timestamp:'14:58'), + RoutePosition(positionId:5,lat:20.4970,lng:-100.8150,speed:0,timestamp:'15:10'), + RoutePosition(positionId:6,lat:20.5010,lng:-100.8120,speed:16,timestamp:'15:22'), + RoutePosition(positionId:7,lat:20.5060,lng:-100.8160,speed:25,timestamp:'15:35'), + RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:40,timestamp:'15:55'), + ]), + RouteModel(routeId:'RUTA-06',name:'Norte Extremo - Rumbos de Roque',truckId:106,status:'EN_RUTA',turno:'MATUTINO',positions:[ + RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'06:00'), + RoutePosition(positionId:2,lat:20.5380,lng:-100.8380,speed:40,timestamp:'06:15'), + RoutePosition(positionId:3,lat:20.5610,lng:-100.8370,speed:30,timestamp:'06:30'), + RoutePosition(positionId:4,lat:20.5750,lng:-100.8360,speed:15,timestamp:'06:45'), + RoutePosition(positionId:5,lat:20.5820,lng:-100.8350,speed:0,timestamp:'07:00'), + RoutePosition(positionId:6,lat:20.5780,lng:-100.8310,speed:20,timestamp:'07:15'), + RoutePosition(positionId:7,lat:20.5650,lng:-100.8320,speed:28,timestamp:'07:30'), + RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:45,timestamp:'07:55'), + ]), + RouteModel(routeId:'RUTA-07',name:'Nororiente - Ciudad Industrial',truckId:107,status:'EN_RUTA',turno:'MATUTINO',positions:[ + RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'06:10'), + RoutePosition(positionId:2,lat:20.5350,lng:-100.8050,speed:44,timestamp:'06:24'), + RoutePosition(positionId:3,lat:20.5450,lng:-100.7950,speed:25,timestamp:'06:38'), + RoutePosition(positionId:4,lat:20.5480,lng:-100.7850,speed:18,timestamp:'06:52'), + RoutePosition(positionId:5,lat:20.5510,lng:-100.7750,speed:0,timestamp:'07:05'), + RoutePosition(positionId:6,lat:20.5460,lng:-100.7720,speed:12,timestamp:'07:18'), + RoutePosition(positionId:7,lat:20.5390,lng:-100.7820,speed:30,timestamp:'07:30'), + RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:42,timestamp:'07:52'), + ]), + RouteModel(routeId:'RUTA-08',name:'Suroriente - Universidad Latina',truckId:108,status:'EN_RUTA',turno:'NOCTURNO',positions:[ + RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'22:00'), + RoutePosition(positionId:2,lat:20.5180,lng:-100.8310,speed:38,timestamp:'22:15'), + RoutePosition(positionId:3,lat:20.5245,lng:-100.7980,speed:30,timestamp:'22:30'), + RoutePosition(positionId:4,lat:20.5210,lng:-100.7995,speed:14,timestamp:'22:45'), + RoutePosition(positionId:5,lat:20.5175,lng:-100.8010,speed:0,timestamp:'23:00'), + RoutePosition(positionId:6,lat:20.5140,lng:-100.8030,speed:18,timestamp:'23:15'), + RoutePosition(positionId:7,lat:20.5110,lng:-100.8055,speed:22,timestamp:'23:30'), + RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:40,timestamp:'23:50'), + ]), + RouteModel(routeId:'RUTA-09',name:'Poniente - Hospital General',truckId:109,status:'EN_RUTA',turno:'MATUTINO',positions:[ + RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'06:02'), + RoutePosition(positionId:2,lat:20.5210,lng:-100.8650,speed:45,timestamp:'06:12'), + RoutePosition(positionId:3,lat:20.5260,lng:-100.8520,speed:26,timestamp:'06:24'), + RoutePosition(positionId:4,lat:20.5275,lng:-100.8490,speed:12,timestamp:'06:36'), + RoutePosition(positionId:5,lat:20.5285,lng:-100.8460,speed:0,timestamp:'06:48'), + RoutePosition(positionId:6,lat:20.5250,lng:-100.8470,speed:15,timestamp:'07:00'), + RoutePosition(positionId:7,lat:20.5220,lng:-100.8550,speed:32,timestamp:'07:12'), + RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:44,timestamp:'07:30'), + ]), + RouteModel(routeId:'RUTA-10',name:'Eje Juan Pablo II - UG Sur',truckId:110,status:'EN_RUTA',turno:'NOCTURNO',positions:[ + RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'21:00'), + RoutePosition(positionId:2,lat:20.5015,lng:-100.8520,speed:40,timestamp:'21:15'), + RoutePosition(positionId:3,lat:20.4990,lng:-100.8390,speed:28,timestamp:'21:30'), + RoutePosition(positionId:4,lat:20.4950,lng:-100.8320,speed:18,timestamp:'21:45'), + RoutePosition(positionId:5,lat:20.4920,lng:-100.8280,speed:0,timestamp:'22:00'), + RoutePosition(positionId:6,lat:20.4945,lng:-100.8240,speed:14,timestamp:'22:15'), + RoutePosition(positionId:7,lat:20.4980,lng:-100.8300,speed:30,timestamp:'22:30'), + RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:38,timestamp:'22:50'), + ]), + RouteModel(routeId:'RUTA-11',name:'Zona de Oro - Torres Landa',truckId:111,status:'EN_RUTA',turno:'MATUTINO',positions:[ + RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'06:04'), + RoutePosition(positionId:2,lat:20.5240,lng:-100.8350,speed:36,timestamp:'06:16'), + RoutePosition(positionId:3,lat:20.5280,lng:-100.8250,speed:22,timestamp:'06:29'), + RoutePosition(positionId:4,lat:20.5295,lng:-100.8210,speed:10,timestamp:'06:42'), + RoutePosition(positionId:5,lat:20.5310,lng:-100.8170,speed:0,timestamp:'06:55'), + RoutePosition(positionId:6,lat:20.5290,lng:-100.8140,speed:16,timestamp:'07:08'), + RoutePosition(positionId:7,lat:20.5260,lng:-100.8220,speed:28,timestamp:'07:21'), + RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:42,timestamp:'07:42'), + ]), + RouteModel(routeId:'RUTA-12',name:'Nororiente - Las Insurgentes',truckId:112,status:'EN_RUTA',turno:'MATUTINO',positions:[ + RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'06:08'), + RoutePosition(positionId:2,lat:20.5280,lng:-100.8080,speed:40,timestamp:'06:22'), + RoutePosition(positionId:3,lat:20.5320,lng:-100.7980,speed:24,timestamp:'06:35'), + RoutePosition(positionId:4,lat:20.5340,lng:-100.7940,speed:15,timestamp:'06:48'), + RoutePosition(positionId:5,lat:20.5360,lng:-100.7900,speed:0,timestamp:'07:00'), + RoutePosition(positionId:6,lat:20.5310,lng:-100.7920,speed:12,timestamp:'07:12'), + RoutePosition(positionId:7,lat:20.5270,lng:-100.8020,speed:26,timestamp:'07:25'), + RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:44,timestamp:'07:48'), + ]), + RouteModel(routeId:'RUTA-13',name:'Sector Norte - Trojes e Irrigación',truckId:113,status:'EN_RUTA',turno:'MATUTINO',positions:[ + RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'06:12'), + RoutePosition(positionId:2,lat:20.5360,lng:-100.8190,speed:35,timestamp:'06:26'), + RoutePosition(positionId:3,lat:20.5420,lng:-100.8080,speed:28,timestamp:'06:40'), + RoutePosition(positionId:4,lat:20.5440,lng:-100.8040,speed:14,timestamp:'06:54'), + RoutePosition(positionId:5,lat:20.5460,lng:-100.8000,speed:0,timestamp:'07:06'), + RoutePosition(positionId:6,lat:20.5410,lng:-100.8020,speed:18,timestamp:'07:18'), + RoutePosition(positionId:7,lat:20.5370,lng:-100.8120,speed:25,timestamp:'07:30'), + RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:39,timestamp:'07:54'), + ]), + RouteModel(routeId:'RUTA-14',name:'Sur Poniente - La Toscana',truckId:114,status:'EN_RUTA',turno:'VESPERTINO',positions:[ + RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'14:16'), + RoutePosition(positionId:2,lat:20.5150,lng:-100.8580,speed:42,timestamp:'14:28'), + RoutePosition(positionId:3,lat:20.5140,lng:-100.8390,speed:26,timestamp:'14:41'), + RoutePosition(positionId:4,lat:20.5125,lng:-100.8310,speed:16,timestamp:'14:54'), + RoutePosition(positionId:5,lat:20.5110,lng:-100.8250,speed:0,timestamp:'15:06'), + RoutePosition(positionId:6,lat:20.5135,lng:-100.8280,speed:12,timestamp:'15:18'), + RoutePosition(positionId:7,lat:20.5160,lng:-100.8420,speed:32,timestamp:'15:30'), + RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:45,timestamp:'15:51'), + ]), + RouteModel(routeId:'RUTA-15',name:'Norponiente - San José de Celaya',truckId:115,status:'EN_RUTA',turno:'NOCTURNO',positions:[ + RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'22:30'), + RoutePosition(positionId:2,lat:20.5320,lng:-100.8590,speed:38,timestamp:'22:45'), + RoutePosition(positionId:3,lat:20.5390,lng:-100.8480,speed:24,timestamp:'23:00'), + RoutePosition(positionId:4,lat:20.5420,lng:-100.8440,speed:15,timestamp:'23:15'), + RoutePosition(positionId:5,lat:20.5450,lng:-100.8410,speed:0,timestamp:'23:30'), + RoutePosition(positionId:6,lat:20.5410,lng:-100.8430,speed:14,timestamp:'23:45'), + RoutePosition(positionId:7,lat:20.5360,lng:-100.8520,speed:28,timestamp:'00:00'), + RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:41,timestamp:'00:20'), + ]), +]; + +RouteModel? getRouteById(String id) { + try { return routesData.firstWhere((r) => r.routeId == id); } + catch (_) { return null; } +} diff --git a/lib/database/db_helper.dart b/lib/database/db_helper.dart new file mode 100644 index 0000000..41ac9ce --- /dev/null +++ b/lib/database/db_helper.dart @@ -0,0 +1,185 @@ +import 'package:sqflite/sqflite.dart'; +import 'package:path/path.dart'; +import '../models/models.dart'; +import '../models/route_model.dart'; + +class DbHelper { + static Database? _db; + + static Future get database async { + _db ??= await _initDb(); + return _db!; + } + + static Future _initDb() async { + final path = join(await getDatabasesPath(), 'celaya_v2.db'); + return openDatabase(path, version: 1, onCreate: _onCreate); + } + + static Future _onCreate(Database db, int v) async { + await db.execute('''CREATE TABLE users( + id INTEGER PRIMARY KEY AUTOINCREMENT, nombre TEXT NOT NULL, + email TEXT UNIQUE NOT NULL, password TEXT NOT NULL, rol TEXT NOT NULL)'''); + + await db.execute('''CREATE TABLE domicilios( + id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, + calle TEXT NOT NULL, colonia TEXT NOT NULL, route_id TEXT NOT NULL, + horario_estimado TEXT NOT NULL, is_primary INTEGER DEFAULT 1)'''); + + await db.execute('''CREATE TABLE asignaciones( + id INTEGER PRIMARY KEY AUTOINCREMENT, conductor_id INTEGER NOT NULL, + route_id TEXT NOT NULL, dia_semana TEXT NOT NULL, turno TEXT NOT NULL)'''); + + await db.execute('''CREATE TABLE route_status( + route_id TEXT PRIMARY KEY, status TEXT NOT NULL, + mensaje TEXT, updated_at TEXT)'''); + + await db.execute('''CREATE TABLE alertas( + id INTEGER PRIMARY KEY AUTOINCREMENT, tipo TEXT NOT NULL, + route_id TEXT NOT NULL, mensaje TEXT NOT NULL, + fecha TEXT NOT NULL, resuelta INTEGER DEFAULT 0)'''); + + await db.execute('''CREATE TABLE reportes( + id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, + tipo TEXT NOT NULL, descripcion TEXT NOT NULL, colonia TEXT NOT NULL, + route_id TEXT, fecha TEXT NOT NULL, estado TEXT DEFAULT 'PENDIENTE', + calificacion INTEGER DEFAULT 5)'''); + + // Seed: admin y conductor demo + await db.insert('users', {'nombre':'Administrador','email':'admin@celaya.gob.mx', + 'password':'admin123','rol':'ADMINISTRADOR'}); + await db.insert('users', {'nombre':'Juan Conductor','email':'conductor@celaya.gob.mx', + 'password':'conductor123','rol':'CONDUCTOR'}); + } + + // ── USERS ────────────────────────────────────────────────────────────── + static Future insertUser(UserModel u) async => + (await database).insert('users', u.toMap(), conflictAlgorithm: ConflictAlgorithm.abort); + + static Future getUserByEmail(String email) async { + final res = await (await database).query('users', where:'email=?', whereArgs:[email]); + return res.isEmpty ? null : UserModel.fromMap(res.first); + } + + static Future getUserById(int id) async { + final res = await (await database).query('users', where:'id=?', whereArgs:[id]); + return res.isEmpty ? null : UserModel.fromMap(res.first); + } + + static Future> getUsersByRol(String rol) async { + final res = await (await database).query('users', where:'rol=?', whereArgs:[rol]); + return res.map((m) => UserModel.fromMap(m)).toList(); + } + + // ── DOMICILIOS ───────────────────────────────────────────────────────── + static Future insertDomicilio(DomicilioModel d) async => + (await database).insert('domicilios', d.toMap()); + + static Future getPrimaryDomicilio(int userId) async { + final res = await (await database).query('domicilios', + where:'user_id=? AND is_primary=1', whereArgs:[userId]); + return res.isEmpty ? null : DomicilioModel.fromMap(res.first); + } + + // ── ASIGNACIONES ─────────────────────────────────────────────────────── + static Future upsertAsignacion(AssignmentModel a) async { + final db = await database; + final ex = await db.query('asignaciones', + where:'conductor_id=? AND dia_semana=?', + whereArgs:[a.conductorId, a.diaSemana]); + if (ex.isEmpty) { + await db.insert('asignaciones', a.toMap()); + } else { + await db.update('asignaciones', {'route_id':a.routeId,'turno':a.turno}, + where:'conductor_id=? AND dia_semana=?', + whereArgs:[a.conductorId, a.diaSemana]); + } + } + + static Future> getAsignacionesByConductor(int conductorId) async { + final res = await (await database).query('asignaciones', + where:'conductor_id=?', whereArgs:[conductorId]); + return res.map((m) => AssignmentModel.fromMap(m)).toList(); + } + + static Future> getAllAsignaciones() async { + final res = await (await database).query('asignaciones'); + return res.map((m) => AssignmentModel.fromMap(m)).toList(); + } + + // ── ROUTE STATUS ─────────────────────────────────────────────────────── + static Future upsertRouteStatus(RouteStatusModel s) async { + final db = await database; + await db.insert('route_status', s.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace); + } + + static Future getRouteStatus(String routeId) async { + final res = await (await database).query('route_status', + where:'route_id=?', whereArgs:[routeId]); + return res.isEmpty ? null : RouteStatusModel.fromMap(res.first); + } + + static Future> getAllRouteStatuses() async { + final res = await (await database).query('route_status'); + return res.map((m) => RouteStatusModel.fromMap(m)).toList(); + } + + // ── ALERTAS ──────────────────────────────────────────────────────────── + static Future insertAlerta(AlertaModel a) async => + (await database).insert('alertas', a.toMap()); + + static Future> getAlertas({bool soloNoResueltas = false}) async { + final db = await database; + final res = soloNoResueltas + ? await db.query('alertas', where:'resuelta=0', orderBy:'fecha DESC') + : await db.query('alertas', orderBy:'fecha DESC'); + return res.map((m) => AlertaModel.fromMap(m)).toList(); + } + + static Future resolverAlerta(int id) async => + (await database).update('alertas', {'resuelta':1}, where:'id=?', whereArgs:[id]); + + // ── REPORTES ─────────────────────────────────────────────────────────── + static Future insertReporte(ReporteModel r) async => + (await database).insert('reportes', r.toMap()); + + static Future> getReportesByUser(int userId) async { + final res = await (await database).query('reportes', + where:'user_id=?', whereArgs:[userId], orderBy:'fecha DESC'); + return res.map((m) => ReporteModel.fromMap(m)).toList(); + } + + static Future> getAllReportes() async { + final res = await (await database).query('reportes', orderBy:'fecha DESC'); + return res.map((m) => ReporteModel.fromMap(m)).toList(); + } + + static Future updateReporteEstado(int id, String estado) async => + (await database).update('reportes', {'estado':estado}, where:'id=?', whereArgs:[id]); + + // ── REPORTES CON INFO DE USUARIO ────────────────────────────────────── + static Future>> getReportesConUsuario() async { + final db = await database; + return db.rawQuery(''' + SELECT r.*, u.nombre as user_nombre, u.email as user_email + FROM reportes r + LEFT JOIN users u ON r.user_id = u.id + ORDER BY r.fecha DESC + '''); + } + + // ── INCIDENTES CONDUCTOR ─────────────────────────────────────────────── + static Future> getIncidentesConductor() async { + final res = await (await database).query('alertas', + where: "tipo LIKE 'INCIDENTE_%'", orderBy: 'fecha DESC'); + return res.map((m) => AlertaModel.fromMap(m)).toList(); + } + + // ── DOMICILIOS POR RUTA ──────────────────────────────────────────────── + static Future> getDomiciliosByRoute(String routeId) async { + final res = await (await database).query('domicilios', + where: 'route_id = ?', whereArgs: [routeId]); + return res.map((m) => DomicilioModel.fromMap(m)).toList(); + } +} diff --git a/lib/main.dart b/lib/main.dart index 244a702..e2a445d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,121 +1,56 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'core/app_colors.dart'; +import 'services/auth_service.dart'; +import 'services/route_simulator_service.dart'; +import 'screens/splash_screen.dart'; +import 'screens/login_screen.dart'; +import 'screens/register_screen.dart'; +import 'screens/citizen/citizen_home_screen.dart'; +import 'screens/driver/driver_home_screen.dart'; +import 'screens/admin/admin_dashboard_screen.dart'; -void main() { - runApp(const MyApp()); +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + runApp(const CelayaLimpiaApp()); } -class MyApp extends StatelessWidget { - const MyApp({super.key}); - - // This widget is the root of your application. - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Flutter Demo', - theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a purple toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. - colorScheme: .fromSeed(seedColor: Colors.deepPurple), - ), - home: const MyHomePage(title: 'Flutter Demo Home Page'), - ); - } -} - -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - - final String title; - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; - }); - } +class CelayaLimpiaApp extends StatelessWidget { + const CelayaLimpiaApp({super.key}); @override Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), - ), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. - mainAxisAlignment: .center, - children: [ - const Text('You have pushed the button this many times:'), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, + return MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => AuthService()), + ChangeNotifierProvider(create: (_) => RouteSimulatorService()), + ], + child: MaterialApp( + title: 'Celaya Limpia', + debugShowCheckedModeBanner: false, + theme: ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: AppColors.guindaPrimary, + primary: AppColors.guindaPrimary, + secondary: AppColors.dorado, + ), + inputDecorationTheme: const InputDecorationTheme( + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.guindaPrimary, width: 2), ), - ], + labelStyle: TextStyle(color: AppColors.guindaPrimary), + ), ), - ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), + initialRoute: '/splash', + routes: { + '/splash': (_) => const SplashScreen(), + '/login': (_) => const LoginScreen(), + '/register': (_) => const RegisterScreen(), + '/home': (_) => const CitizenHomeScreen(), + '/driver': (_) => const DriverHomeScreen(), + '/admin': (_) => const AdminDashboardScreen(), + }, ), ); } diff --git a/lib/models/models.dart b/lib/models/models.dart new file mode 100644 index 0000000..5dc4de5 --- /dev/null +++ b/lib/models/models.dart @@ -0,0 +1,126 @@ +// ── USER ────────────────────────────────────────────────────────────────── +class UserModel { + final int? id; + final String nombre; + final String email; + final String password; + final String rol; // CIUDADANO | CONDUCTOR | ADMINISTRADOR + + UserModel({this.id, required this.nombre, required this.email, + required this.password, required this.rol}); + + Map toMap() => + {'id': id, 'nombre': nombre, 'email': email, 'password': password, 'rol': rol}; + + factory UserModel.fromMap(Map m) => UserModel( + id: m['id'], nombre: m['nombre'], email: m['email'], + password: m['password'], rol: m['rol']); +} + +// ── DOMICILIO (citizen) ─────────────────────────────────────────────────── +class DomicilioModel { + final int? id; + final int userId; + final String calle; + final String colonia; + final String routeId; + final String horarioEstimado; + final bool isPrimary; + + DomicilioModel({this.id, required this.userId, required this.calle, + required this.colonia, required this.routeId, + required this.horarioEstimado, this.isPrimary = true}); + + Map toMap() => {'id': id, 'user_id': userId, 'calle': calle, + 'colonia': colonia, 'route_id': routeId, + 'horario_estimado': horarioEstimado, 'is_primary': isPrimary ? 1 : 0}; + + factory DomicilioModel.fromMap(Map m) => DomicilioModel( + id: m['id'], userId: m['user_id'], calle: m['calle'], + colonia: m['colonia'], routeId: m['route_id'], + horarioEstimado: m['horario_estimado'], isPrimary: m['is_primary'] == 1); +} + +// ── ASSIGNMENT (driver schedule) ────────────────────────────────────────── +class AssignmentModel { + final int? id; + final int conductorId; + final String routeId; + final String diaSemana; + final String turno; // MATUTINO | VESPERTINO | NOCTURNO + + AssignmentModel({this.id, required this.conductorId, required this.routeId, + required this.diaSemana, required this.turno}); + + Map toMap() => {'id': id, 'conductor_id': conductorId, + 'route_id': routeId, 'dia_semana': diaSemana, 'turno': turno}; + + factory AssignmentModel.fromMap(Map m) => AssignmentModel( + id: m['id'], conductorId: m['conductor_id'], routeId: m['route_id'], + diaSemana: m['dia_semana'], turno: m['turno']); +} + +// ── ROUTE STATUS ────────────────────────────────────────────────────────── +class RouteStatusModel { + final String routeId; + final String status; + final String? mensaje; + final String updatedAt; + + RouteStatusModel({required this.routeId, required this.status, + this.mensaje, required this.updatedAt}); + + Map toMap() => {'route_id': routeId, 'status': status, + 'mensaje': mensaje, 'updated_at': updatedAt}; + + factory RouteStatusModel.fromMap(Map m) => RouteStatusModel( + routeId: m['route_id'], status: m['status'], + mensaje: m['mensaje'], updatedAt: m['updated_at']); +} + +// ── ALERTA ──────────────────────────────────────────────────────────────── +class AlertaModel { + final int? id; + final String tipo; // GPS_PERDIDO | CAMION_DETENIDO | FALLA_MECANICA + final String routeId; + final String mensaje; + final String fecha; + final bool resuelta; + + AlertaModel({this.id, required this.tipo, required this.routeId, + required this.mensaje, required this.fecha, this.resuelta = false}); + + Map toMap() => {'id': id, 'tipo': tipo, 'route_id': routeId, + 'mensaje': mensaje, 'fecha': fecha, 'resuelta': resuelta ? 1 : 0}; + + factory AlertaModel.fromMap(Map m) => AlertaModel( + id: m['id'], tipo: m['tipo'], routeId: m['route_id'], + mensaje: m['mensaje'], fecha: m['fecha'], resuelta: m['resuelta'] == 1); +} + +// ── REPORTE ─────────────────────────────────────────────────────────────── +class ReporteModel { + final int? id; + final int userId; + final String tipo; + final String descripcion; + final String colonia; + final String routeId; + final String fecha; + final String estado; + final int calificacion; + + ReporteModel({this.id, required this.userId, required this.tipo, + required this.descripcion, required this.colonia, required this.routeId, + required this.fecha, this.estado = 'PENDIENTE', this.calificacion = 5}); + + Map toMap() => {'id': id, 'user_id': userId, 'tipo': tipo, + 'descripcion': descripcion, 'colonia': colonia, 'route_id': routeId, + 'fecha': fecha, 'estado': estado, 'calificacion': calificacion}; + + factory ReporteModel.fromMap(Map m) => ReporteModel( + id: m['id'], userId: m['user_id'], tipo: m['tipo'], + descripcion: m['descripcion'], colonia: m['colonia'], + routeId: m['route_id'] ?? '', fecha: m['fecha'], + estado: m['estado'], calificacion: m['calificacion'] ?? 5); +} diff --git a/lib/models/route_model.dart b/lib/models/route_model.dart new file mode 100644 index 0000000..7a8d5e6 --- /dev/null +++ b/lib/models/route_model.dart @@ -0,0 +1,39 @@ +import 'package:latlong2/latlong.dart'; + +class RoutePosition { + final int positionId; + final double lat; + final double lng; + final int speed; + final String timestamp; + + RoutePosition({required this.positionId, required this.lat, + required this.lng, required this.speed, required this.timestamp}); + + LatLng get latLng => LatLng(lat, lng); +} + +class RouteModel { + final String routeId; + final String name; + final int truckId; + String status; + final List positions; + final String turno; // MATUTINO | VESPERTINO | NOCTURNO + + RouteModel({required this.routeId, required this.name, + required this.truckId, required this.status, + required this.positions, this.turno = 'MATUTINO'}); + + List get polylinePoints => + positions.map((p) => LatLng(p.lat, p.lng)).toList(); +} + +class ColonyModel { + final String colonia; + final String routeId; + final String horarioEstimado; + + ColonyModel({required this.colonia, required this.routeId, + required this.horarioEstimado}); +} diff --git a/lib/screens/admin/admin_dashboard_screen.dart b/lib/screens/admin/admin_dashboard_screen.dart new file mode 100644 index 0000000..2c07ffc --- /dev/null +++ b/lib/screens/admin/admin_dashboard_screen.dart @@ -0,0 +1,786 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../core/app_colors.dart'; +import '../../services/auth_service.dart'; +import '../../services/route_simulator_service.dart'; +import '../../database/db_helper.dart'; +import '../../models/models.dart'; +import '../../data/routes_data.dart'; +import '../../widgets/route_map_widget.dart'; + +class AdminDashboardScreen extends StatefulWidget { + const AdminDashboardScreen({super.key}); + @override State createState() => _AdminDashboardScreenState(); +} + +class _AdminDashboardScreenState extends State { + int _tab = 0; + @override + Widget build(BuildContext context) { + final sim = context.watch(); + final auth = context.watch(); + final last = sim.lastNotification; + + final tabs = [ + _AdminHomeTab(sim:sim, auth:auth), + _AdminMapTab(sim:sim), + _AdminReportesTab(), + _AdminAssignmentsTab(), + _AdminAlertasTab(sim:sim), + ]; + + return Scaffold( + body: Stack(children:[ + tabs[_tab], + if (last!=null) Positioned(top:MediaQuery.of(context).padding.top+8,left:0,right:0, + child:_AdminBanner(notif:last,onDismiss:sim.dismissNotification)), + ]), + bottomNavigationBar: NavigationBar( + selectedIndex:_tab, + onDestinationSelected:(i)=>setState(()=>_tab=i), + backgroundColor:Colors.white, + indicatorColor:AppColors.verdeAdmin.withOpacity(0.15), + destinations:const[ + NavigationDestination(icon:Icon(Icons.dashboard_outlined), + selectedIcon:Icon(Icons.dashboard,color:AppColors.verdeAdmin),label:'Panel'), + NavigationDestination(icon:Icon(Icons.map_outlined), + selectedIcon:Icon(Icons.map,color:AppColors.verdeAdmin),label:'Mapa'), + NavigationDestination(icon:Icon(Icons.report_outlined), + selectedIcon:Icon(Icons.report,color:AppColors.verdeAdmin),label:'Reportes'), + NavigationDestination(icon:Icon(Icons.calendar_month_outlined), + selectedIcon:Icon(Icons.calendar_month,color:AppColors.verdeAdmin),label:'Asignar'), + NavigationDestination(icon:Icon(Icons.warning_outlined), + selectedIcon:Icon(Icons.warning,color:AppColors.verdeAdmin),label:'Alertas'), + ], + ), + ); + } +} + +// ── TAB 1: Control de rutas ─────────────────────────────────────────────── +class _AdminHomeTab extends StatefulWidget { + final RouteSimulatorService sim; final AuthService auth; + const _AdminHomeTab({required this.sim, required this.auth}); + @override State<_AdminHomeTab> createState() => _AdminHomeTabState(); +} + +class _AdminHomeTabState extends State<_AdminHomeTab> { + List _statuses = []; + List _conductorIncidentes = []; + + @override void initState() { super.initState(); _load(); } + + Future _load() async { + final s = await DbHelper.getAllRouteStatuses(); + final inc = await DbHelper.getIncidentesConductor(); + if (mounted) setState(() { _statuses = s; _conductorIncidentes = inc; }); + } + + String _getStatus(String rid) { + try { return _statuses.firstWhere((s) => s.routeId == rid).status; } + catch (_) { return RouteStatus.enRuta; } + } + + String? _getMensaje(String rid) { + try { return _statuses.firstWhere((s) => s.routeId == rid).mensaje; } + catch (_) { return null; } + } + + // Incidentes del conductor asociados a esta ruta (por número) + List _getIncidentesPorRuta(String routeId) { + return _conductorIncidentes + .where((i) => !i.resuelta) + .where((i) => i.routeId.contains(routeId) || + // Si es incidente de conductor sin routeId específico, mostrar en todas + i.routeId.startsWith('CONDUCTOR-')) + .take(2) + .toList(); + } + + Future _changeStatus(String routeId, String status, String? msg) async { + await DbHelper.upsertRouteStatus(RouteStatusModel( + routeId: routeId, status: status, mensaje: msg, + updatedAt: DateTime.now().toIso8601String())); + + if (status == RouteStatus.cancelada || status == RouteStatus.fallaMecanica || status == RouteStatus.retrasada) { + final emoji = status == RouteStatus.cancelada ? '❌' + : status == RouteStatus.fallaMecanica ? '🔧' : '⏱️'; + final titulo = status == RouteStatus.cancelada ? 'Ruta Cancelada' + : status == RouteStatus.fallaMecanica ? 'Falla Mecánica' : 'Servicio con Retraso'; + final cuerpo = (msg != null && msg.isNotEmpty) + ? '$emoji $msg' + : '$emoji La ruta $routeId ${status == RouteStatus.cancelada ? "ha sido cancelada hoy" : status == RouteStatus.fallaMecanica ? "reportó una falla mecánica" : "presenta un retraso"}. Pendiente reprogramación.'; + widget.sim.fireCustomNotification(titulo, cuerpo, routeId, + status == RouteStatus.cancelada ? NotifEvent.routeCancelled : NotifEvent.truckStopped); + await DbHelper.insertAlerta(AlertaModel( + tipo: 'RUTA_$status', routeId: routeId, mensaje: cuerpo, + fecha: DateTime.now().toIso8601String())); + } + await _load(); + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return CustomScrollView(slivers: [ + SliverAppBar(pinned: true, backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white, + bottom: PreferredSize(preferredSize: const Size.fromHeight(4), + child: Container(height: 4, color: AppColors.dorado)), + title: const Text('Panel Administrador', style: TextStyle(fontWeight: FontWeight.bold)), + actions: [ + IconButton(icon: const Icon(Icons.refresh), onPressed: _load), + IconButton(icon: const Icon(Icons.logout), + onPressed: () async { await widget.auth.logout(); + if (context.mounted) Navigator.pushReplacementNamed(context, '/login'); }), + ], + ), + SliverPadding(padding: const EdgeInsets.all(12), sliver: SliverList(delegate: SliverChildListDelegate([ + Row(children: [ + _Stat('Rutas', '${routesData.length}', Icons.local_shipping, AppColors.verdeAdmin), + const SizedBox(width: 10), + _Stat('Incidentes', '${_conductorIncidentes.where((i)=>!i.resuelta).length}', + Icons.warning, AppColors.naranjaAlerta), + ]), + const SizedBox(height: 14), + const Text('Control de Rutas', style: TextStyle(fontWeight: FontWeight.bold, + fontSize: 16, color: AppColors.verdeAdmin)), + const SizedBox(height: 8), + ...routesData.map((r) { + final status = _getStatus(r.routeId); + final mensaje = _getMensaje(r.routeId); + final gpsOk = widget.sim.isGpsActive(r.routeId); + final nightIcon = r.turno == 'NOCTURNO' ? '🌙 ' : r.turno == 'VESPERTINO' ? '🌅 ' : '🌄 '; + final incidentes = _getIncidentesPorRuta(r.routeId); + + return Card(margin: const EdgeInsets.only(bottom: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10), + side: BorderSide(color: RouteStatus.color(status).withOpacity(0.4), width: 1.2)), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Cabecera ruta + ListTile(dense: true, + leading: Container(width: 8, height: 44, + decoration: BoxDecoration(color: RouteStatus.color(status), + borderRadius: BorderRadius.circular(4))), + title: Text('${r.routeId} — ${r.name}', + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700)), + subtitle: Wrap(spacing: 6, children: [ + Text(RouteStatus.label(status), + style: TextStyle(fontSize: 11, color: RouteStatus.color(status), fontWeight: FontWeight.w600)), + if (!gpsOk) + const Text('📡 Sin GPS', style: TextStyle(fontSize: 10, color: AppColors.rojoError)), + Text(nightIcon + r.turno, style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)), + ]), + trailing: PopupMenuButton( + icon: const Icon(Icons.more_vert, size: 18), + onSelected: (v) async { + if (v == 'GPS') { widget.sim.simulateGpsLost(r.routeId); return; } + if (v == 'RESTORE') { widget.sim.restoreGps(r.routeId); return; } + String? msg; + if (v == RouteStatus.retrasada) { + final res = await _retrasadaDialog(context); + if (res != null) { + final parts = res.split('|'); + final nuevoTurno = parts[0]; + final extra = parts.length > 1 ? parts[1] : ''; + msg = 'Ruta reprogramada al turno $nuevoTurno. $extra'.trim(); + } + } else if ([RouteStatus.cancelada, RouteStatus.fallaMecanica].contains(v)) { + msg = await _inputDialog(context, 'Mensaje / solución para ciudadanos'); + } + await _changeStatus(r.routeId, v, msg); + }, + itemBuilder: (_) => [ + const PopupMenuItem(value: 'EN_RUTA', child: Text('✅ En Ruta — Continúa')), + const PopupMenuItem(value: 'RETRASADA', child: Text('⏱️ Marcar Retrasada')), + const PopupMenuItem(value: 'CANCELADA', child: Text('❌ Cancelar y Notificar')), + const PopupMenuItem(value: 'FALLA_MECANICA', child: Text('🔧 Falla Mecánica')), + const PopupMenuDivider(), + const PopupMenuItem(value: 'GPS', child: Text('📡 Simular GPS Perdido')), + const PopupMenuItem(value: 'RESTORE', child: Text('📶 Restaurar GPS')), + ], + ), + ), + + // Mensaje del admin si hay + if (mensaje != null && mensaje.isNotEmpty && status != RouteStatus.enRuta) + Padding( + padding: const EdgeInsets.fromLTRB(14, 0, 14, 8), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: RouteStatus.color(status).withOpacity(0.08), + borderRadius: BorderRadius.circular(6), + ), + child: Row(children: [ + Icon(Icons.message_outlined, size: 13, color: RouteStatus.color(status)), + const SizedBox(width: 6), + Expanded(child: Text('Msg ciudadanos: $mensaje', + style: TextStyle(fontSize: 11, color: RouteStatus.color(status)))), + ]), + ), + ), + + // Incidentes de conductor pendientes para esta ruta + if (incidentes.isNotEmpty) ...[ + const Divider(height: 1, indent: 14, endIndent: 14), + Padding( + padding: const EdgeInsets.fromLTRB(14, 6, 14, 2), + child: Row(children: [ + const Icon(Icons.build, size: 13, color: AppColors.moradoConductor), + const SizedBox(width: 4), + const Text('Incidentes del conductor:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 11, + color: AppColors.moradoConductor)), + ]), + ), + ...incidentes.map((inc) => Padding( + padding: const EdgeInsets.fromLTRB(14, 2, 14, 2), + child: Row(children: [ + Container(width: 6, height: 6, + decoration: const BoxDecoration(color: AppColors.moradoConductor, + shape: BoxShape.circle)), + const SizedBox(width: 6), + Expanded(child: Text(inc.mensaje, + style: const TextStyle(fontSize: 11), maxLines: 1, + overflow: TextOverflow.ellipsis)), + TextButton( + onPressed: () async { + // Mostrar diálogo: ¿qué hacer con este incidente? + final accion = await _incidenteDialog(context, inc.mensaje); + if (accion != null) { + await DbHelper.resolverAlerta(inc.id!); + // Soporta formato RETRASADA:TURNO para reprogramación + String realStatus = accion; + String msg = 'Incidente: ${inc.mensaje.substring(0, inc.mensaje.length.clamp(0, 40))}'; + if (accion.startsWith('RETRASADA:')) { + final parts = accion.split(':'); + realStatus = 'RETRASADA'; + final turno = parts.length > 1 ? parts[1] : 'VESPERTINO'; + msg = 'Tu ruta ha sido reprogramada al turno $turno por incidente del conductor. ' + 'Recibirás notificación cuando el camión esté listo.'; + } + await _changeStatus(r.routeId, realStatus, msg); + } + }, + style: TextButton.styleFrom( + foregroundColor: AppColors.verdeAdmin, + padding: const EdgeInsets.symmetric(horizontal: 8)), + child: const Text('Actuar', style: TextStyle(fontSize: 10)), + ), + ]), + )), + const SizedBox(height: 6), + ], + ])); + }), + const SizedBox(height: 80), + ]))), + ]); + } + + Future _inputDialog(BuildContext ctx, String hint) async { + final ctrl = TextEditingController(); + return showDialog(context: ctx, builder: (_) => AlertDialog( + title: const Text('Mensaje para ciudadanos'), + content: TextField(controller: ctrl, maxLines: 2, + decoration: InputDecoration(hintText: hint, border: const OutlineInputBorder())), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancelar')), + ElevatedButton(onPressed: () => Navigator.pop(ctx, ctrl.text), child: const Text('Enviar')), + ])); + } + + Future _retrasadaDialog(BuildContext ctx) async { + String turno = 'VESPERTINO'; + final ctrl = TextEditingController(); + return showDialog(context: ctx, builder: (dCtx) => StatefulBuilder( + builder: (dCtx, setSt) => AlertDialog( + title: const Text('Reprogramar Ruta'), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('¿A qué turno pasará el camión?', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + const SizedBox(height: 8), + Row(children: [ + Expanded(child: RadioListTile(dense: true, value: 'MATUTINO', + groupValue: turno, title: const Text('🌄 Matutino'), + onChanged: (v) => setSt(() => turno = v!))), + Expanded(child: RadioListTile(dense: true, value: 'VESPERTINO', + groupValue: turno, title: const Text('🌅 Vespertino'), + onChanged: (v) => setSt(() => turno = v!))), + ]), + const SizedBox(height: 8), + TextField(controller: ctrl, maxLines: 2, + decoration: const InputDecoration( + hintText: 'Mensaje adicional para ciudadanos (opcional)', + border: OutlineInputBorder(), isDense: true)), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(dCtx), child: const Text('Cancelar')), + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: AppColors.naranjaAlerta, + foregroundColor: Colors.white), + onPressed: () => Navigator.pop(dCtx, '$turno|${ctrl.text.trim()}'), + child: const Text('Confirmar')), + ]))); + } + + Future _incidenteDialog(BuildContext ctx, String incMensaje) async { + String turnoSeleccionado = 'VESPERTINO'; + return showDialog(context: ctx, builder: (dialogCtx) => StatefulBuilder( + builder: (dialogCtx, setDialogState) => AlertDialog( + title: const Text('Acción sobre el incidente'), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(incMensaje, style: const TextStyle(fontSize: 12, color: AppColors.grisTexto)), + const Divider(), + const Text('Si decides reprogramar, ¿a qué turno?', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)), + const SizedBox(height: 6), + Row(children: [ + Expanded(child: RadioListTile(dense: true, value: 'MATUTINO', + groupValue: turnoSeleccionado, title: const Text('🌄 Matutino', style: TextStyle(fontSize: 12)), + onChanged: (v) => setDialogState(() => turnoSeleccionado = v!))), + Expanded(child: RadioListTile(dense: true, value: 'VESPERTINO', + groupValue: turnoSeleccionado, title: const Text('🌅 Vespertino', style: TextStyle(fontSize: 12)), + onChanged: (v) => setDialogState(() => turnoSeleccionado = v!))), + ]), + const SizedBox(height: 4), + const Text('¿Qué decisión tomas?', style: TextStyle(fontWeight: FontWeight.bold)), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(dialogCtx), child: const Text('Cerrar')), + ElevatedButton.icon( + style: ElevatedButton.styleFrom(backgroundColor: AppColors.verdeExito, foregroundColor: Colors.white), + onPressed: () => Navigator.pop(dialogCtx, 'EN_RUTA'), + icon: const Icon(Icons.check, size: 14), + label: const Text('Continúa', style: TextStyle(fontSize: 12))), + ElevatedButton.icon( + style: ElevatedButton.styleFrom(backgroundColor: AppColors.naranjaAlerta, foregroundColor: Colors.white), + onPressed: () => Navigator.pop(dialogCtx, 'RETRASADA:$turnoSeleccionado'), + icon: const Icon(Icons.access_time, size: 14), + label: Text('Retraso→$turnoSeleccionado', style: const TextStyle(fontSize: 11))), + ElevatedButton.icon( + style: ElevatedButton.styleFrom(backgroundColor: AppColors.rojoError, foregroundColor: Colors.white), + onPressed: () => Navigator.pop(dialogCtx, 'CANCELADA'), + icon: const Icon(Icons.cancel, size: 14), + label: const Text('Cancelar', style: TextStyle(fontSize: 12))), + ]))); + } +} + +class _AdminMapTab extends StatelessWidget { + final RouteSimulatorService sim; + const _AdminMapTab({required this.sim}); + @override + Widget build(BuildContext context) => Scaffold( + appBar:AppBar(automaticallyImplyLeading:false, + backgroundColor:AppColors.verdeAdmin,foregroundColor:Colors.white, + title:const Text('Mapa — Todas las Rutas'), + bottom:PreferredSize(preferredSize:const Size.fromHeight(4), + child:Container(height:4,color:AppColors.dorado))), + body:AdminMapWidget(routes:routesData,simulator:sim)); +} + +// ── TAB 3: Reportes ciudadanos ──────────────────────────────────────────── +class _AdminReportesTab extends StatefulWidget { + @override State<_AdminReportesTab> createState() => _AdminReportesTabState(); +} + +class _AdminReportesTabState extends State<_AdminReportesTab> { + List> _reportes = []; + bool _loading = true; + + @override void initState() { super.initState(); _load(); } + + Future _load() async { + final r = await DbHelper.getReportesConUsuario(); + if (mounted) setState(() { _reportes=r; _loading=false; }); + } + + static const _tipos = { + 'CAMION_NO_PASO':'🚛 No pasó','RETRASO':'⏱️ Retraso', + 'RESIDUOS_NO_RECOGIDOS':'🗑️ No recogidos','OTRO':'📝 Otro', + }; + + @override + Widget build(BuildContext context) => Scaffold( + appBar:AppBar(automaticallyImplyLeading:false, + backgroundColor:AppColors.verdeAdmin,foregroundColor:Colors.white, + title:Text('Reportes Ciudadanos (${_reportes.length})'), + bottom:PreferredSize(preferredSize:const Size.fromHeight(4), + child:Container(height:4,color:AppColors.dorado)), + actions:[IconButton(icon:const Icon(Icons.refresh),onPressed:_load)]), + body:_loading?const Center(child:CircularProgressIndicator()) + :_reportes.isEmpty?const Center(child:Text('Sin reportes')) + :ListView.builder(padding:const EdgeInsets.all(12), + itemCount:_reportes.length, + itemBuilder:(_,i){ + final r = _reportes[i]; + final tipo = r['tipo']??''; + final calif = r['calificacion']??5; + final nombre = r['user_nombre']??'Usuario desconocido'; + final email = r['user_email']??''; + final colonia = r['colonia']??''; + final routeId = r['route_id']??''; + final estado = r['estado']??'PENDIENTE'; + final id = r['id'] as int?; + return Card(margin:const EdgeInsets.only(bottom:8), + child:Padding(padding:const EdgeInsets.all(12),child:Column( + crossAxisAlignment:CrossAxisAlignment.start, children:[ + // Quién reportó + Row(children:[ + const Icon(Icons.person,color:AppColors.verdeAdmin,size:14), + const SizedBox(width:4), + Expanded(child:Text('$nombre ($email)', + style:const TextStyle(fontWeight:FontWeight.bold,fontSize:12,color:AppColors.verdeAdmin))), + Container(padding:const EdgeInsets.symmetric(horizontal:6,vertical:2), + decoration:BoxDecoration(color:_estadoColor(estado).withOpacity(0.15), + borderRadius:BorderRadius.circular(10)), + child:Text(estado,style:TextStyle(fontSize:9,color:_estadoColor(estado), + fontWeight:FontWeight.bold))), + ]), + const SizedBox(height:4), + Row(children:[ + const Icon(Icons.location_city,color:AppColors.grisTexto,size:12), + const SizedBox(width:4), + Text('$colonia — $routeId',style:const TextStyle(color:AppColors.grisTexto,fontSize:11)), + ]), + const SizedBox(height:6), + Text(_tipos[tipo]??tipo,style:const TextStyle(fontWeight:FontWeight.w600,fontSize:13)), + Text(r['descripcion']??'',style:const TextStyle(fontSize:12,color:AppColors.grisTexto)), + const SizedBox(height:6), + Row(children:[ + Text('⭐'*calif,style:const TextStyle(fontSize:11)), + const Spacer(), + PopupMenuButton( + child:Text(estado,style:TextStyle(fontSize:11,color:_estadoColor(estado), + fontWeight:FontWeight.bold,decoration:TextDecoration.underline)), + onSelected:(v)async{ + if(id!=null) await DbHelper.updateReporteEstado(id,v); + await _load(); + }, + itemBuilder:(_)=>['PENDIENTE','EN_REVISION','RESUELTO','DESESTIMADO'] + .map((e)=>PopupMenuItem(value:e,child:Text(e))).toList()), + ]), + ]))); + }), + ); + + Color _estadoColor(String e){ + switch(e){case'RESUELTO':return AppColors.verdeExito; + case'EN_REVISION':return AppColors.azulInfo; + case'DESESTIMADO':return AppColors.grisTexto; + default:return AppColors.naranjaAlerta;} + } +} + +// ── TAB 4: Asignaciones ─────────────────────────────────────────────────── +// ── TAB 4: Asignaciones LMV / MJS ──────────────────────────────────────── +class _AdminAssignmentsTab extends StatefulWidget { + @override State<_AdminAssignmentsTab> createState() => _AdminAssignmentsTabState(); +} + +class _AdminAssignmentsTabState extends State<_AdminAssignmentsTab> { + List _conductores = []; + UserModel? _sel; + List _asigs = []; + + // Grupos fijos de días + static const _grupoA = ['LUNES','MIERCOLES','VIERNES']; + static const _grupoB = ['MARTES','JUEVES','SABADO']; + + @override void initState() { super.initState(); _load(); } + + Future _load() async { + final c = await DbHelper.getUsersByRol('CONDUCTOR'); + if (mounted) setState(() => _conductores = c); + } + + Future _loadAsigs(int id) async { + final a = await DbHelper.getAsignacionesByConductor(id); + if (mounted) setState(() => _asigs = a); + } + + // Obtener asignación de un grupo (busca cualquier día del grupo) + AssignmentModel? _getGrupo(List dias) { + for (final dia in dias) { + try { return _asigs.firstWhere((a) => a.diaSemana == dia); } catch (_) {} + } + return null; + } + + // Guardar asignación para todos los días del grupo + Future _saveGrupo(List dias, String routeId, String turno) async { + for (final dia in dias) { + await DbHelper.upsertAsignacion(AssignmentModel( + conductorId: _sel!.id!, routeId: routeId, + diaSemana: dia, turno: turno)); + } + await _loadAsigs(_sel!.id!); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(automaticallyImplyLeading: false, + backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white, + title: const Text('Asignar Rutas a Conductores'), + bottom: PreferredSize(preferredSize: const Size.fromHeight(4), + child: Container(height: 4, color: AppColors.dorado))), + body: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(children: [ + // Info de esquema + Container(padding: const EdgeInsets.all(10), margin: const EdgeInsets.only(bottom:12), + decoration: BoxDecoration(color: Colors.blue.shade50, borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue.shade200)), + child: const Text( + '📅 Cada conductor opera en uno de dos bloques:\n' + ' Grupo A — Lunes, Miércoles y Viernes\n' + ' Grupo B — Martes, Jueves y Sábado', + style: TextStyle(fontSize: 12, color: AppColors.azulInfo))), + + DropdownButtonFormField( + decoration: const InputDecoration(labelText: 'Selecciona conductor', + border: OutlineInputBorder(), filled: true, fillColor: Colors.white), + hint: const Text('Conductor...'), + value: _sel, + items: _conductores.map((c) => DropdownMenuItem(value: c, + child: Text(c.nombre, style: const TextStyle(fontSize: 13)))).toList(), + onChanged: (c) { setState(() => _sel = c); if (c != null) _loadAsigs(c.id!); }), + + if (_sel != null) ...[ + const SizedBox(height: 20), + // GRUPO A + _GrupoRow( + label: 'Grupo A — Lunes, Miércoles y Viernes', + icon: Icons.wb_sunny_outlined, + color: Colors.blue, + current: _getGrupo(_grupoA), + routeIds: routesData.map((r) => r.routeId).toList(), + onSave: (rid, turno) => _saveGrupo(_grupoA, rid, turno), + ), + const SizedBox(height: 12), + // GRUPO B + _GrupoRow( + label: 'Grupo B — Martes, Jueves y Sábado', + icon: Icons.wb_twilight, + color: Colors.deepPurple, + current: _getGrupo(_grupoB), + routeIds: routesData.map((r) => r.routeId).toList(), + onSave: (rid, turno) => _saveGrupo(_grupoB, rid, turno), + ), + + // Resumen actual + if (_asigs.isNotEmpty) ...[ + const SizedBox(height: 20), + const Text('Resumen actual', style: TextStyle(fontWeight: FontWeight.bold, + color: AppColors.verdeAdmin, fontSize: 14)), + const SizedBox(height: 8), + Card(child: Padding(padding: const EdgeInsets.all(12), child: Column(children: [ + ..._asigs.map((a) => Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row(children: [ + SizedBox(width: 100, child: Text(AppDias.label(a.diaSemana), + style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 12))), + Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration(color: AppColors.verdeAdmin.withOpacity(0.1), + borderRadius: BorderRadius.circular(10)), + child: Text('${a.routeId} • ${a.turno}', + style: const TextStyle(fontSize: 11, color: AppColors.verdeAdmin))), + ]))), + ]))), + ], + ], + ])), + ); +} + +// Fila de asignación por grupo (LMV o MJS) +class _GrupoRow extends StatefulWidget { + final String label; + final IconData icon; + final Color color; + final AssignmentModel? current; + final List routeIds; + final Function(String, String) onSave; + const _GrupoRow({required this.label, required this.icon, required this.color, + required this.current, required this.routeIds, required this.onSave}); + @override State<_GrupoRow> createState() => _GrupoRowState(); +} + +class _GrupoRowState extends State<_GrupoRow> { + String? _route; + String _turno = 'MATUTINO'; + + @override void initState() { + super.initState(); + _route = widget.current?.routeId; + _turno = widget.current?.turno ?? 'MATUTINO'; + } + + @override + Widget build(BuildContext context) => Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10), + side: BorderSide(color: widget.color.withOpacity(0.3))), + child: Padding(padding: const EdgeInsets.all(14), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Icon(widget.icon, color: widget.color, size: 18), + const SizedBox(width: 8), + Expanded(child: Text(widget.label, + style: TextStyle(fontWeight: FontWeight.bold, color: widget.color, fontSize: 13))), + ]), + const SizedBox(height: 12), + Row(children: [ + Expanded(child: DropdownButtonFormField( + value: _route, + decoration: const InputDecoration(labelText: 'Ruta', + border: OutlineInputBorder(), isDense: true, + contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 10)), + hint: const Text('Sin ruta', style: TextStyle(fontSize: 12)), + items: widget.routeIds.map((r) => DropdownMenuItem(value: r, + child: Text(r, style: const TextStyle(fontSize: 12)))).toList(), + onChanged: (v) => setState(() => _route = v))), + const SizedBox(width: 8), + SizedBox(width: 140, child: DropdownButtonFormField( + value: _turno, + decoration: const InputDecoration(labelText: 'Turno', + border: OutlineInputBorder(), isDense: true, + contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 10)), + items: const [ + DropdownMenuItem(value:'MATUTINO', child:Text('🌄 Matutino',style:TextStyle(fontSize:12))), + DropdownMenuItem(value:'VESPERTINO',child:Text('🌅 Vespertino',style:TextStyle(fontSize:12))), + DropdownMenuItem(value:'NOCTURNO', child:Text('🌙 Nocturno',style:TextStyle(fontSize:12))), + ], + onChanged: (v) => setState(() => _turno = v!))), + const SizedBox(width: 8), + ElevatedButton( + onPressed: _route == null ? null : () => widget.onSave(_route!, _turno), + style: ElevatedButton.styleFrom( + backgroundColor: widget.color, foregroundColor: Colors.white, + minimumSize: const Size(50, 42), padding: EdgeInsets.zero, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + child: const Icon(Icons.save, size: 18)), + ]), + ]))); +} + +class _AdminAlertasTab extends StatefulWidget { + final RouteSimulatorService sim; + const _AdminAlertasTab({required this.sim}); + @override State<_AdminAlertasTab> createState() => _AdminAlertasTabState(); +} + +class _AdminAlertasTabState extends State<_AdminAlertasTab> { + List _alertas = []; + bool _soloActivas = false; + + @override void initState(){ super.initState(); _load(); } + + Future _load() async { + final a = await DbHelper.getAlertas(soloNoResueltas:_soloActivas); + if (mounted) setState(()=>_alertas=a); + } + + IconData _icon(String tipo){ + if(tipo.startsWith('INCIDENTE_')) return Icons.build; + switch(tipo){ + case'GPS_PERDIDO': return Icons.gps_off; + case'CAMION_DETENIDO': return Icons.warning_amber; + default: return Icons.info; + } + } + Color _color(String tipo){ + if(tipo.startsWith('INCIDENTE_')) return AppColors.moradoConductor; + switch(tipo){ + case'GPS_PERDIDO': return AppColors.rojoError; + case'CAMION_DETENIDO': return AppColors.naranjaAlerta; + case'RUTA_CANCELADA': return AppColors.rojoError; + default: return AppColors.azulInfo; + } + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar:AppBar(automaticallyImplyLeading:false, + backgroundColor:AppColors.verdeAdmin,foregroundColor:Colors.white, + title:Text('Alertas (${_alertas.where((a)=>!a.resuelta).length} activas)'), + bottom:PreferredSize(preferredSize:const Size.fromHeight(4), + child:Container(height:4,color:AppColors.dorado)), + actions:[ + Switch(value:_soloActivas,onChanged:(v){setState(()=>_soloActivas=v);_load();}, + activeColor:AppColors.dorado), + IconButton(icon:const Icon(Icons.refresh),onPressed:_load), + ]), + body:_alertas.isEmpty + ?Center(child:Column(mainAxisAlignment:MainAxisAlignment.center,children:[ + const Icon(Icons.check_circle,color:AppColors.verdeExito,size:48), + const SizedBox(height:8), + Text(_soloActivas?'Sin alertas activas':'Sin alertas registradas', + style:const TextStyle(color:AppColors.grisTexto))])) + :ListView.builder(padding:const EdgeInsets.all(12), + itemCount:_alertas.length, + itemBuilder:(_,i){ + final a = _alertas[i]; + final esIncidente = a.tipo.startsWith('INCIDENTE_'); + return Card(margin:const EdgeInsets.only(bottom:8), + color:a.resuelta?Colors.grey.shade50:null, + child:ListTile( + leading:CircleAvatar(backgroundColor:a.resuelta?Colors.grey:_color(a.tipo), + child:Icon(_icon(a.tipo),color:Colors.white,size:18)), + title:Row(children:[ + if(esIncidente) Container(margin:const EdgeInsets.only(right:6), + padding:const EdgeInsets.symmetric(horizontal:6,vertical:2), + decoration:BoxDecoration(color:AppColors.moradoConductor.withOpacity(0.1), + borderRadius:BorderRadius.circular(8)), + child:const Text('CONDUCTOR',style:TextStyle(fontSize:9,color:AppColors.moradoConductor,fontWeight:FontWeight.bold))), + Expanded(child:Text('${a.tipo.replaceAll('_',' ')} — ${a.routeId}', + style:TextStyle(fontSize:12,fontWeight:FontWeight.bold, + color:a.resuelta?AppColors.grisTexto:AppColors.negroTexto))), + ]), + subtitle:Text(a.mensaje,style:const TextStyle(fontSize:11)), + trailing:a.resuelta + ?const Icon(Icons.check_circle,color:AppColors.verdeExito,size:20) + :TextButton( + onPressed:()async{ await DbHelper.resolverAlerta(a.id!); await _load(); }, + style:TextButton.styleFrom(foregroundColor:AppColors.verdeAdmin), + child:const Text('Resolver',style:TextStyle(fontSize:11))), + )); + }), + ); +} + +// ── Widgets ─────────────────────────────────────────────────────────────── +class _Stat extends StatelessWidget { + final String label,value; final IconData icon; final Color color; + const _Stat(this.label,this.value,this.icon,this.color); + @override + Widget build(BuildContext context) => Expanded(child:Card( + child:Padding(padding:const EdgeInsets.all(14),child:Row(children:[ + Icon(icon,color:color,size:28), + const SizedBox(width:10), + Column(crossAxisAlignment:CrossAxisAlignment.start,children:[ + Text(value,style:TextStyle(fontSize:22,fontWeight:FontWeight.bold,color:color)), + Text(label,style:const TextStyle(fontSize:11,color:AppColors.grisTexto)), + ]), + ])))); +} + +class _AdminBanner extends StatelessWidget { + final AppNotification notif; final VoidCallback onDismiss; + const _AdminBanner({required this.notif,required this.onDismiss}); + @override + Widget build(BuildContext context) => Material(color:Colors.transparent, + child:Container(margin:const EdgeInsets.all(10), + decoration:BoxDecoration( + color:notif.event==NotifEvent.routeCancelled?AppColors.rojoError:AppColors.rojoError, + borderRadius:BorderRadius.circular(12), + boxShadow:const[BoxShadow(color:Colors.black26,blurRadius:6)]), + child:Padding(padding:const EdgeInsets.all(12),child:Row(children:[ + const Icon(Icons.admin_panel_settings,color:Colors.white,size:22), + const SizedBox(width:8), + Expanded(child:Column(crossAxisAlignment:CrossAxisAlignment.start,mainAxisSize:MainAxisSize.min,children:[ + Text(notif.title,style:const TextStyle(color:Colors.white,fontWeight:FontWeight.bold,fontSize:13)), + Text(notif.body,style:const TextStyle(color:Colors.white70,fontSize:11), + maxLines:2,overflow:TextOverflow.ellipsis), + ])), + IconButton(icon:const Icon(Icons.close,color:Colors.white,size:18),onPressed:onDismiss), + ])))); +} diff --git a/lib/screens/citizen/ai_camera_screen.dart b/lib/screens/citizen/ai_camera_screen.dart new file mode 100644 index 0000000..414eeb8 --- /dev/null +++ b/lib/screens/citizen/ai_camera_screen.dart @@ -0,0 +1,175 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:camera/camera.dart'; +import 'package:tflite_flutter/tflite_flutter.dart'; +import 'package:image/image.dart' as img; +import '../../core/app_colors.dart'; + +List _cameras = []; + +class AiCameraScreen extends StatefulWidget { + const AiCameraScreen({super.key}); + @override State createState() => _AiCameraScreenState(); +} + +class _AiCameraScreenState extends State { + CameraController? _cam; + Interpreter? _interpreter; + bool _processing = false; + String _result = 'Apunta a un residuo y escanea'; + String _confidence = ''; + bool _modelLoaded = false; + + // 0=Orgánico, 1=Inorgánico (según waste_classification_model) + final _labels = ['Residuo Orgánico ♻️', 'Residuo Inorgánico 🗑️']; + final _labelColors = [AppColors.verdeExito, AppColors.naranjaAlerta]; + + @override + void initState() { + super.initState(); + _init(); + } + + Future _init() async { + try { + _cameras = await availableCameras(); + } catch (_) {} + await _initCamera(); + await _loadModel(); + } + + Future _initCamera() async { + if (_cameras.isEmpty) return; + _cam = CameraController(_cameras[0], ResolutionPreset.medium, enableAudio: false); + try { + await _cam!.initialize(); + if (mounted) setState(() {}); + } catch (_) {} + } + + Future _loadModel() async { + try { + _interpreter = await Interpreter.fromAsset('assets/models/waste_model.tflite'); + setState(() => _modelLoaded = true); + } catch (e) { + setState(() => _result = '⚠️ Modelo no encontrado.\nAgrega waste_model.tflite a assets/models/'); + } + } + + Future _classify() async { + if (_cam == null || !_cam!.value.isInitialized || _processing || !_modelLoaded) return; + setState(() { _processing = true; _result = 'Analizando...'; _confidence = ''; }); + try { + final pic = await _cam!.takePicture(); + final raw = await File(pic.path).readAsBytes(); + img.Image? decoded = img.decodeImage(raw); + if (decoded == null) throw Exception('No se pudo decodificar'); + final resized = img.copyResize(decoded, width: 150, height: 150); + + var input = List.generate(1, (_) => + List.generate(150, (_) => List.generate(150, (_) => List.generate(3, (_) => 0.0)))); + + for (int y = 0; y < 150; y++) { + for (int x = 0; x < 150; x++) { + final px = resized.getPixel(x, y); + input[0][y][x][0] = px.r / 255.0; + input[0][y][x][1] = px.g / 255.0; + input[0][y][x][2] = px.b / 255.0; + } + } + var output = List.filled(2, 0.0).reshape([1, 2]); + _interpreter!.run(input, output); + + final pred = List.from(output[0]); + final maxIdx = pred[0] > pred[1] ? 0 : 1; + final conf = pred[maxIdx] * 100; + + await File(pic.path).delete(); + setState(() { + _result = _labels[maxIdx]; + _confidence = 'Confianza: ${conf.toStringAsFixed(1)}%'; + }); + } catch (e) { + setState(() => _result = 'Error en análisis'); + } finally { + setState(() => _processing = false); + } + } + + @override + void dispose() { + _cam?.dispose(); + _interpreter?.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final resultColor = _result.contains('Orgánico') ? AppColors.verdeExito + : _result.contains('Inorgánico') ? AppColors.naranjaAlerta + : AppColors.guindaPrimary; + + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white, + title: const Text('Clasificador IA de Residuos'), + bottom: PreferredSize(preferredSize: const Size.fromHeight(4), + child: Container(height: 4, color: AppColors.dorado)), + ), + body: Column(children: [ + // Visor cámara + Expanded(flex: 4, + child: Container(margin: const EdgeInsets.all(14), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), + border: Border.all(color: AppColors.guindaPrimary, width: 3)), + child: _cam != null && _cam!.value.isInitialized + ? CameraPreview(_cam!) + : const Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(Icons.camera_alt, color: Colors.white54, size: 48), + SizedBox(height: 8), + Text('Iniciando cámara...', style: TextStyle(color: Colors.white54)), + ])), + ), + ), + // Panel resultado + Expanded(flex: 2, + child: Container(width: double.infinity, + decoration: BoxDecoration(color: AppColors.guindaPrimary.withOpacity(0.06), + borderRadius: const BorderRadius.vertical(top: Radius.circular(28))), + padding: const EdgeInsets.all(20), + child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Text(_result, textAlign: TextAlign.center, + style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: resultColor)), + if (_confidence.isNotEmpty) ...[ + const SizedBox(height: 6), + Text(_confidence, style: const TextStyle(fontSize: 16, color: Colors.black54, fontWeight: FontWeight.w500)), + ], + const SizedBox(height: 16), + if (!_modelLoaded) + Container(padding: const EdgeInsets.all(10), + decoration: BoxDecoration(color: Colors.orange.shade50, borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.orange.shade300)), + child: const Text('ℹ️ Para usar la IA, coloca waste_model.tflite en assets/models/', + textAlign: TextAlign.center, style: TextStyle(fontSize: 11))), + if (_modelLoaded) + SizedBox(width: double.infinity, height: 50, + child: ElevatedButton.icon( + onPressed: _processing ? null : _classify, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14))), + icon: _processing + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) + : const Icon(Icons.center_focus_strong), + label: Text(_processing ? 'Procesando...' : 'Escanear Residuo', + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + )), + ]), + ), + ), + ]), + ); + } +} diff --git a/lib/screens/citizen/citizen_guia_screen.dart b/lib/screens/citizen/citizen_guia_screen.dart new file mode 100644 index 0000000..2742dfa --- /dev/null +++ b/lib/screens/citizen/citizen_guia_screen.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import '../../core/app_colors.dart'; +import 'ai_camera_screen.dart'; + +class CitizenGuiaScreen extends StatelessWidget { + const CitizenGuiaScreen({super.key}); + + static const _cats = [ + _Cat(Icons.grass,Color(0xFF2E7D32),'Orgánicos','Restos de comida, jardín','🟢 Bolsa Verde',[ + 'Frutas y verduras','Cáscaras de huevo','Posos de café y té', + 'Restos de comida preparada','Pasto y hojas','Cáscaras de semillas'], + ['Aceites en exceso','Carnes en grandes cantidades']), + _Cat(Icons.recycling,Color(0xFF1565C0),'Reciclables','Papel, plástico, vidrio, metal','🔵 Bolsa Azul',[ + 'Botellas PET','Latas de aluminio','Cartón y papel limpio', + 'Vidrio (botellas, frascos)','Periódico y revistas'], + ['Vidrio roto sin envolver','Papel sucio o mojado','Unicel']), + _Cat(Icons.delete,Color(0xFF757575),'No Reciclables','Residuos que no se reusan','⚫ Bolsa Negra',[ + 'Pañales desechables','Toallas sanitarias','Papel higiénico usado', + 'Colillas de cigarro','Cerámica rota'],['Baterías','Medicamentos','Aceite usado']), + _Cat(Icons.warning_amber,Color(0xFFC62828),'Peligrosos','Requieren manejo especial','🔴 Separado',[ + 'Agujas y jeringas','Medicamentos vencidos','Pilas y baterías', + 'Aceite de cocina usado','Pintura y solventes'],[],isWarn:true), + _Cat(Icons.devices_other,Color(0xFFE65100),'Electrónicos (RAEE)','Aparatos electrónicos','🟠 Punto de acopio',[ + 'Celulares viejos','Computadoras','Televisiones', + 'Focos ahorradores','Cables y cargadores'],[],isSpecial:true), + ]; + + @override + Widget build(BuildContext context) => Scaffold( + backgroundColor: AppColors.grisFondo, + appBar: AppBar(automaticallyImplyLeading:false, + backgroundColor:AppColors.guindaPrimary, foregroundColor:Colors.white, + title:const Text('Guía de Separación'), + actions:[IconButton(icon:const Icon(Icons.camera_alt), + tooltip:'Clasificar con IA', + onPressed:()=>Navigator.push(context,MaterialPageRoute(builder:(_)=>const AiCameraScreen())))], + bottom:PreferredSize(preferredSize:const Size.fromHeight(4), + child:Container(height:4,color:AppColors.dorado))), + body:Column(children:[ + Container(width:double.infinity, + color:AppColors.verdeExito.withOpacity(0.1), + padding:const EdgeInsets.symmetric(horizontal:16,vertical:8), + child:Row(children:[ + const Icon(Icons.offline_bolt,color:AppColors.verdeExito,size:16), + const SizedBox(width:6), + const Text('Disponible sin conexión a internet', + style:TextStyle(color:AppColors.verdeExito,fontSize:12,fontWeight:FontWeight.w500)), + const Spacer(), + TextButton.icon(icon:const Icon(Icons.camera_alt,size:14), + label:const Text('Clasificar IA',style:TextStyle(fontSize:12)), + style:TextButton.styleFrom(foregroundColor:AppColors.guindaPrimary), + onPressed:()=>Navigator.push(context,MaterialPageRoute(builder:(_)=>const AiCameraScreen()))), + ])), + // Importancia de separar + Container(margin:const EdgeInsets.fromLTRB(12,8,12,0), + padding:const EdgeInsets.all(12), + decoration:BoxDecoration(color:Colors.green.shade50,borderRadius:BorderRadius.circular(8), + border:Border.all(color:Colors.green.shade200)), + child:const Column(crossAxisAlignment:CrossAxisAlignment.start, children:[ + Text('¿Por qué separar tu basura?',style:TextStyle(fontWeight:FontWeight.bold,color:Color(0xFF2E7D32))), + SizedBox(height:6), + Text('♻️ El 60% de los residuos en México pueden reciclarse o compostarse, pero solo el 5% lo hace.\n' + '🌱 Separar correctamente reduce la contaminación del suelo y agua, genera empleos verdes ' + 'y disminuye los gases de efecto invernadero producidos en rellenos sanitarios.', + style:TextStyle(fontSize:12,color:Colors.black87)), + ])), + Expanded(child:ListView.builder( + padding:const EdgeInsets.all(12), + itemCount:_cats.length, + itemBuilder:(ctx,i)=>_CatCard(cat:_cats[i]))), + ]), + ); +} + +class _Cat { + final IconData icon; final Color color; final String title, subtitle, bolsa; + final List items, noItems; + final bool isWarn, isSpecial; + const _Cat(this.icon,this.color,this.title,this.subtitle,this.bolsa, + this.items,this.noItems,{this.isWarn=false,this.isSpecial=false}); +} + +class _CatCard extends StatefulWidget { + final _Cat cat; + const _CatCard({super.key, required this.cat}); + @override State<_CatCard> createState() => _CatCardState(); +} + +class _CatCardState extends State<_CatCard> { + bool _open = false; + @override + Widget build(BuildContext context) { + final c = widget.cat; + return Card(margin:const EdgeInsets.only(bottom:10),elevation:2, + shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(10), + side:BorderSide(color:c.color.withOpacity(0.3))), + child:InkWell(borderRadius:BorderRadius.circular(10), + onTap:()=>setState(()=>_open=!_open), + child:Column(children:[ + Container(decoration:BoxDecoration(color:c.color.withOpacity(0.1), + borderRadius:BorderRadius.vertical(top:const Radius.circular(10), + bottom:_open?Radius.zero:const Radius.circular(10))), + padding:const EdgeInsets.all(14), + child:Row(children:[ + Container(width:40,height:40,decoration:BoxDecoration(color:c.color,borderRadius:BorderRadius.circular(8)), + child:Icon(c.icon,color:Colors.white,size:22)), + const SizedBox(width:10), + Expanded(child:Column(crossAxisAlignment:CrossAxisAlignment.start, children:[ + Text(c.title,style:TextStyle(fontWeight:FontWeight.bold,fontSize:15,color:c.color)), + Text(c.subtitle,style:const TextStyle(color:AppColors.grisTexto,fontSize:11)), + Text(c.bolsa,style:TextStyle(fontSize:11,fontWeight:FontWeight.w600,color:c.color)), + ])), + Icon(_open?Icons.expand_less:Icons.expand_more,color:c.color), + ])), + if (_open) Padding(padding:const EdgeInsets.fromLTRB(14,0,14,14), + child:Column(crossAxisAlignment:CrossAxisAlignment.start, children:[ + const SizedBox(height:8), + Text('✅ Qué va aquí:',style:TextStyle(fontWeight:FontWeight.bold,color:c.color,fontSize:12)), + const SizedBox(height:4), + ...c.items.map((e)=>Padding(padding:const EdgeInsets.symmetric(vertical:2), + child:Row(children:[Icon(Icons.check_circle_outline,size:13,color:c.color), + const SizedBox(width:6),Text(e,style:const TextStyle(fontSize:12))]))), + if (c.noItems.isNotEmpty) ...[ + const SizedBox(height:8), + const Text('❌ NO incluir:',style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.rojoError,fontSize:12)), + ...c.noItems.map((e)=>Padding(padding:const EdgeInsets.symmetric(vertical:2), + child:Row(children:[const Icon(Icons.cancel_outlined,size:13,color:AppColors.rojoError), + const SizedBox(width:6),Text(e,style:const TextStyle(fontSize:12,color:AppColors.rojoError))]))), + ], + if (c.isSpecial) ...[ + const SizedBox(height:8), + Container(padding:const EdgeInsets.all(8), + decoration:BoxDecoration(color:Colors.orange.shade50,borderRadius:BorderRadius.circular(6), + border:Border.all(color:Colors.orange.shade200)), + child:const Text('📍 Lleva a puntos de acopio autorizados por el municipio.', + style:TextStyle(fontSize:11))), + ], + if (c.isWarn) ...[ + const SizedBox(height:8), + Container(padding:const EdgeInsets.all(8), + decoration:BoxDecoration(color:Colors.red.shade50,borderRadius:BorderRadius.circular(6), + border:Border.all(color:Colors.red.shade200)), + child:const Text('⚠️ NUNCA mezcles residuos peligrosos con basura común.', + style:TextStyle(fontSize:11))), + ], + ])), + ]))); + } +} diff --git a/lib/screens/citizen/citizen_home_screen.dart b/lib/screens/citizen/citizen_home_screen.dart new file mode 100644 index 0000000..b2bf232 --- /dev/null +++ b/lib/screens/citizen/citizen_home_screen.dart @@ -0,0 +1,448 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../core/app_colors.dart'; +import '../../services/auth_service.dart'; +import '../../services/route_simulator_service.dart'; +import '../../database/db_helper.dart'; +import '../../models/models.dart'; +import '../../data/routes_data.dart'; +import '../../widgets/route_map_widget.dart'; +import 'citizen_guia_screen.dart'; +import 'citizen_reporte_screen.dart'; + +class CitizenHomeScreen extends StatefulWidget { + const CitizenHomeScreen({super.key}); + @override State createState() => _CitizenHomeScreenState(); +} + +class _CitizenHomeScreenState extends State { + int _tab = 0; + + @override + Widget build(BuildContext context) { + final auth = context.watch(); + final sim = context.watch(); + final dom = auth.primaryDomicilio; // domicilio del ciudadano + final last = dom != null ? sim.getNotificationForRoute(dom.routeId) : null; + + final tabs = [ + _HomeTab(auth: auth, sim: sim), + const CitizenGuiaScreen(), + const CitizenReporteScreen(), + ]; + + return Scaffold( + backgroundColor: AppColors.grisFondo, + body: Stack(children: [ + tabs[_tab], + if (last != null) + Positioned( + top: MediaQuery.of(context).padding.top + 8, left: 0, right: 0, + child: _NotifBanner(notif: last, onDismiss: () => sim.dismissRouteNotification(dom?.routeId ?? '')), + ), + ]), + bottomNavigationBar: NavigationBar( + selectedIndex: _tab, + onDestinationSelected: (i) => setState(() => _tab = i), + backgroundColor: Colors.white, + indicatorColor: AppColors.guindaPrimary.withOpacity(0.15), + destinations: const [ + NavigationDestination(icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home, color: AppColors.guindaPrimary), label: 'Inicio'), + NavigationDestination(icon: Icon(Icons.eco_outlined), + selectedIcon: Icon(Icons.eco, color: AppColors.guindaPrimary), label: 'Guía'), + NavigationDestination(icon: Icon(Icons.report_outlined), + selectedIcon: Icon(Icons.report, color: AppColors.guindaPrimary), label: 'Reportar'), + ], + ), + ); + } +} + +// ── Tab principal (StatefulWidget para cargar status de ruta) ───────────── +class _HomeTab extends StatefulWidget { + final AuthService auth; + final RouteSimulatorService sim; + const _HomeTab({required this.auth, required this.sim}); + @override State<_HomeTab> createState() => _HomeTabState(); +} + +class _HomeTabState extends State<_HomeTab> { + RouteStatusModel? _routeStatus; + + @override + void initState() { + super.initState(); + _loadStatus(); + } + + Future _loadStatus() async { + final dom = widget.auth.primaryDomicilio; + if (dom == null) return; + final s = await DbHelper.getRouteStatus(dom.routeId); + if (mounted) setState(() => _routeStatus = s); + } + + bool get _isRouteProblematic { + final s = _routeStatus?.status ?? RouteStatus.enRuta; + return s == RouteStatus.cancelada || + s == RouteStatus.fallaMecanica || + s == RouteStatus.retrasada; + } + + @override + Widget build(BuildContext context) { + final dom = widget.auth.primaryDomicilio; + final routeId = dom?.routeId ?? ''; + final route = dom != null ? getRouteById(dom.routeId) : null; + final isTruckClose = widget.sim.isTruckClose(routeId); + final status = _routeStatus?.status ?? RouteStatus.enRuta; + + return RefreshIndicator( + onRefresh: _loadStatus, + child: CustomScrollView(slivers: [ + SliverAppBar( + expandedHeight: 120, pinned: true, + backgroundColor: AppColors.guindaPrimary, + bottom: PreferredSize(preferredSize: const Size.fromHeight(4), + child: Container(height: 4, color: AppColors.dorado)), + flexibleSpace: FlexibleSpaceBar( + background: Container( + color: AppColors.guindaPrimary, + padding: const EdgeInsets.fromLTRB(20, 50, 20, 16), + child: Row(children: [ + const Icon(Icons.delete_sweep_rounded, color: AppColors.dorado, size: 30), + const SizedBox(width: 12), + Expanded(child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Hola, ${widget.auth.currentUser?.nombre.split(' ').first ?? ''}', + style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)), + const Text('Celaya Limpia', style: TextStyle(color: AppColors.dorado, fontSize: 12)), + ], + )), + IconButton( + icon: const Icon(Icons.logout, color: Colors.white70), + onPressed: () async { + await widget.auth.logout(); + if (context.mounted) Navigator.pushReplacementNamed(context, '/login'); + }, + ), + ]), + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverList(delegate: SliverChildListDelegate([ + + // ── Si la ruta tiene problema → mostrar alerta en vez de ETA/mapa + if (_isRouteProblematic) ...[ + _RouteStatusBanner(status: _routeStatus!), + const SizedBox(height: 12), + ] else ...[ + // ETA Card normal + _EtaCard(sim: widget.sim, routeId: routeId, dom: dom, route: route), + const SizedBox(height: 12), + // Mapa solo cuando camión está cerca + if (isTruckClose && route != null) ...[ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.orange.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.orange.shade300), + ), + child: const Row(children: [ + Icon(Icons.location_on, color: Colors.orange, size: 18), + SizedBox(width: 6), + Expanded(child: Text('📍 El camión está cerca — mapa activado', + style: TextStyle(fontWeight: FontWeight.bold, color: Colors.orange, fontSize: 12))), + ]), + ), + const SizedBox(height: 8), + RouteMapWidget(route: route, simulator: widget.sim, height: 220), + const SizedBox(height: 12), + ], + ], + + // Aviso privacidad + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.amber.shade50, borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.amber.shade300), + ), + child: const Row(children: [ + Icon(Icons.shield_outlined, color: Colors.amber, size: 18), + SizedBox(width: 6), + Expanded(child: Text('🔒 Solo ves la información de tu ruta asignada.', + style: TextStyle(fontSize: 11, color: Colors.black87))), + ]), + ), + const SizedBox(height: 12), + + // Info domicilio + if (dom != null) + Card(child: Padding( + padding: const EdgeInsets.all(14), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Row(children: [ + Icon(Icons.location_on, color: AppColors.guindaPrimary, size: 16), + SizedBox(width: 6), + Text('Mi Domicilio', style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)), + ]), + const Divider(), + Text(dom.calle, style: const TextStyle(fontSize: 13)), + Text('${dom.colonia} — ${dom.routeId}', + style: const TextStyle(color: AppColors.grisTexto, fontSize: 12)), + Text(dom.horarioEstimado, + style: const TextStyle(color: AppColors.grisTexto, fontSize: 11)), + ]), + )), + + // Historial notificaciones + if (widget.sim.history.isNotEmpty) ...[ + const SizedBox(height: 12), + Card(child: Padding( + padding: const EdgeInsets.all(14), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('Alertas recientes', + style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)), + const Divider(), + ...widget.sim.history.take(4).map((n) { + final color = n.event == NotifEvent.truckProximity + ? AppColors.naranjaAlerta + : n.event == NotifEvent.routeCompleted + ? AppColors.verdeExito + : n.event == NotifEvent.routeCancelled + ? AppColors.rojoError + : AppColors.azulInfo; + final icon = n.event == NotifEvent.truckProximity + ? Icons.warning_amber + : n.event == NotifEvent.routeCompleted + ? Icons.check_circle + : n.event == NotifEvent.routeCancelled + ? Icons.cancel + : Icons.local_shipping; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row(children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 6), + Expanded(child: Text(n.title, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500))), + Text( + '${n.timestamp.hour.toString().padLeft(2, '0')}:${n.timestamp.minute.toString().padLeft(2, '0')}', + style: const TextStyle(fontSize: 10, color: AppColors.grisTexto), + ), + ]), + ); + }), + ]), + )), + ], + const SizedBox(height: 80), + ])), + ), + ]), + ); + } +} + +// ── Banner de ruta con problema ─────────────────────────────────────────── +class _RouteStatusBanner extends StatelessWidget { + final RouteStatusModel status; + const _RouteStatusBanner({required this.status}); + + @override + Widget build(BuildContext context) { + final isCancelled = status.status == RouteStatus.cancelada; + final isFalla = status.status == RouteStatus.fallaMecanica; + final isRetrasada = status.status == RouteStatus.retrasada; + + final color = isCancelled ? AppColors.rojoError + : isFalla ? Colors.red.shade800 + : AppColors.naranjaAlerta; + + final icon = isCancelled ? Icons.cancel + : isFalla ? Icons.build + : Icons.access_time; + + final titulo = isCancelled ? '❌ Ruta Cancelada Hoy' + : isFalla ? '🔧 Falla Mecánica en Servicio' + : '⏱️ Servicio con Retraso'; + + final descripcion = isCancelled + ? 'El servicio de recolección de tu colonia no se realizará hoy. Favor de guardar tus residuos para la próxima jornada.' + : isFalla + ? 'El camión asignado a tu sector presentó una falla mecánica. El Ayuntamiento está atendiendo la situación.' + : 'El camión de tu sector presenta un retraso en su recorrido. El servicio se realizará, pero con demora.'; + + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Alerta principal + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(12), + boxShadow: [BoxShadow(color: color.withOpacity(0.4), blurRadius: 8, offset: const Offset(0, 4))], + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Icon(icon, color: Colors.white, size: 26), + const SizedBox(width: 10), + Expanded(child: Text(titulo, + style: const TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold))), + ]), + const SizedBox(height: 10), + Text(descripcion, style: const TextStyle(color: Colors.white, fontSize: 13, height: 1.4)), + ]), + ), + + // Mensaje del administrador (posible solución) + if (status.mensaje != null && status.mensaje!.isNotEmpty) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withOpacity(0.4)), + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Icon(Icons.admin_panel_settings, color: color, size: 16), + const SizedBox(width: 6), + Text('Mensaje del Ayuntamiento', + style: TextStyle(fontWeight: FontWeight.bold, color: color, fontSize: 13)), + ]), + const SizedBox(height: 6), + Text(status.mensaje!, + style: const TextStyle(fontSize: 13, color: AppColors.negroTexto, height: 1.4)), + ]), + ), + ], + + // Consejo ciudadano + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('💡 Recomendaciones:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: AppColors.grisTexto)), + const SizedBox(height: 4), + if (isCancelled) + const Text('• Guarda tus bolsas en un lugar cerrado\n' + '• No dejes residuos en la acera\n' + '• Revisa la app mañana para el horario actualizado', + style: TextStyle(fontSize: 12, color: AppColors.grisTexto)), + if (isFalla) + const Text('• Espera confirmación del Ayuntamiento\n' + '• Puede enviarse una unidad de reemplazo\n' + '• Revisa las alertas en esta pantalla', + style: TextStyle(fontSize: 12, color: AppColors.grisTexto)), + if (isRetrasada) + const Text('• Tu basura será recogida hoy, con demora\n' + '• Puedes sacar tus bolsas cuando recibas la alerta\n' + '• Recibirás notificación cuando el camión se acerque', + style: TextStyle(fontSize: 12, color: AppColors.grisTexto)), + ]), + ), + ]); + } +} + +// ── ETA Card ────────────────────────────────────────────────────────────── +class _EtaCard extends StatelessWidget { + final RouteSimulatorService sim; + final String routeId; + final dom; final route; + const _EtaCard({required this.sim, required this.routeId, required this.dom, required this.route}); + + @override + Widget build(BuildContext context) => Container( + decoration: BoxDecoration( + gradient: const LinearGradient(colors: [AppColors.guindaPrimary, AppColors.guindaDark], + begin: Alignment.topLeft, end: Alignment.bottomRight), + borderRadius: BorderRadius.circular(14), + boxShadow: [BoxShadow(color: AppColors.guindaDark.withOpacity(0.4), + blurRadius: 8, offset: const Offset(0, 4))], + ), + padding: const EdgeInsets.all(18), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + const Icon(Icons.local_shipping, color: AppColors.dorado, size: 22), + const SizedBox(width: 8), + Expanded(child: Text(route?.name ?? 'Ruta asignada', + style: const TextStyle(color: AppColors.dorado, fontSize: 13, fontWeight: FontWeight.w600))), + ]), + const SizedBox(height: 8), + Text(sim.getEtaText(routeId), + style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + if (dom != null) + Text('⏰ ${dom.horarioEstimado}', + style: const TextStyle(color: Colors.white60, fontSize: 11)), + const SizedBox(height: 10), + LinearProgressIndicator( + value: route != null + ? (sim.getPositionIndex(routeId) + 1) / route.positions.length : 0, + backgroundColor: Colors.white24, + valueColor: const AlwaysStoppedAnimation(AppColors.dorado), + ), + ]), + ); +} + +// ── Banner notificación ─────────────────────────────────────────────────── +class _NotifBanner extends StatelessWidget { + final AppNotification notif; final VoidCallback onDismiss; + const _NotifBanner({required this.notif, required this.onDismiss}); + + @override + Widget build(BuildContext context) { + final color = notif.event == NotifEvent.truckProximity + ? AppColors.naranjaAlerta + : notif.event == NotifEvent.routeCompleted + ? AppColors.verdeExito + : notif.event == NotifEvent.routeCancelled + ? AppColors.rojoError + : notif.event == NotifEvent.gpsLost + ? Colors.red.shade800 + : AppColors.azulInfo; + + return Material( + color: Colors.transparent, + child: Container( + margin: const EdgeInsets.all(12), + decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(12), + boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 8, offset: Offset(0, 4))]), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row(children: [ + const Icon(Icons.notifications_active, color: Colors.white, size: 24), + const SizedBox(width: 10), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ + Text(notif.title, style: const TextStyle(color: Colors.white, + fontWeight: FontWeight.bold, fontSize: 13)), + Text(notif.body, style: const TextStyle(color: Colors.white70, fontSize: 11), + maxLines: 2, overflow: TextOverflow.ellipsis), + ])), + IconButton(icon: const Icon(Icons.close, color: Colors.white, size: 18), + onPressed: onDismiss), + ]), + ), + ), + ); + } +} diff --git a/lib/screens/citizen/citizen_reporte_screen.dart b/lib/screens/citizen/citizen_reporte_screen.dart new file mode 100644 index 0000000..37db5cb --- /dev/null +++ b/lib/screens/citizen/citizen_reporte_screen.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../core/app_colors.dart'; +import '../../database/db_helper.dart'; +import '../../models/models.dart'; +import '../../services/auth_service.dart'; + +class CitizenReporteScreen extends StatefulWidget { + const CitizenReporteScreen({super.key}); + @override State createState() => _CitizenReporteScreenState(); +} + +class _CitizenReporteScreenState extends State { + String _tipo = 'CAMION_NO_PASO'; + final _desc = TextEditingController(); + int _calif = 5; + bool _loading = false, _sent = false; + List _reportes = []; + + static const _tipos = { + 'CAMION_NO_PASO':'🚛 El camión no pasó', + 'RETRASO':'⏱️ Retraso significativo', + 'RESIDUOS_NO_RECOGIDOS':'🗑️ Residuos no recogidos', + 'OTRO':'📝 Otro motivo', + }; + + @override void initState() { super.initState(); _load(); } + + Future _load() async { + final auth = context.read(); + if (auth.currentUser == null) return; + final r = await DbHelper.getReportesByUser(auth.currentUser!.id!); + if (mounted) setState(() => _reportes = r); + } + + Future _send() async { + final auth = context.read(); + if (auth.currentUser == null || _desc.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('Describe el problema'), backgroundColor: AppColors.rojoError)); return; + } + setState(() => _loading = true); + await DbHelper.insertReporte(ReporteModel( + userId: auth.currentUser!.id!, tipo: _tipo, descripcion: _desc.text.trim(), + colonia: auth.primaryDomicilio?.colonia ?? '', + routeId: auth.primaryDomicilio?.routeId ?? '', + fecha: DateTime.now().toIso8601String(), calificacion: _calif, + )); + await _load(); + if (!mounted) return; + setState(() { _loading = false; _sent = true; _desc.clear(); }); + await Future.delayed(const Duration(seconds: 2)); + if (mounted) setState(() => _sent = false); + } + + @override + Widget build(BuildContext context) => Scaffold( + backgroundColor: AppColors.grisFondo, + appBar: AppBar(automaticallyImplyLeading: false, + backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white, + title: const Text('Reportar Incidencia'), + bottom: PreferredSize(preferredSize: const Size.fromHeight(4), + child: Container(height: 4, color: AppColors.dorado))), + body: _sent ? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + const Icon(Icons.check_circle, color: AppColors.verdeExito, size: 64), + const SizedBox(height: 12), + const Text('¡Reporte enviado!', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: AppColors.verdeExito)), + const Text('El Ayuntamiento lo revisará pronto.', style: TextStyle(color: AppColors.grisTexto)), + ])) : SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(children: [ + Card(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding(padding: const EdgeInsets.all(16), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('Nueva Incidencia', style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.guindaPrimary, fontSize: 16)), + const SizedBox(height: 12), + const Text('Tipo:', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13)), + ..._tipos.entries.map((e) => RadioListTile(dense: true, value: e.key, + groupValue: _tipo, title: Text(e.value, style: const TextStyle(fontSize: 13)), + activeColor: AppColors.guindaPrimary, + onChanged: (v) => setState(() => _tipo = v!))), + const SizedBox(height: 8), + DropdownButtonFormField(value: _calif, + decoration: const InputDecoration(labelText: 'Calificación', border: OutlineInputBorder()), + items: [5,4,3,2,1].map((n) => DropdownMenuItem(value: n, + child: Text(['⭐⭐⭐⭐⭐ Excelente','⭐⭐⭐⭐ Bueno','⭐⭐⭐ Regular','⭐⭐ Malo','⭐ Muy malo'][5-n]))).toList(), + onChanged: (v) => setState(() => _calif = v!)), + const SizedBox(height: 10), + TextField(controller: _desc, maxLines: 3, + decoration: const InputDecoration(hintText: 'Describe el problema...', + border: OutlineInputBorder(), filled: true, fillColor: Colors.white)), + const SizedBox(height: 14), + SizedBox(width: double.infinity, height: 48, + child: ElevatedButton.icon(onPressed: _loading ? null : _send, + style: ElevatedButton.styleFrom(backgroundColor: AppColors.guindaPrimary, + foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + icon: _loading ? const SizedBox(width: 18, height: 18, + child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) : const Icon(Icons.send), + label: const Text('ENVIAR REPORTE', style: TextStyle(fontWeight: FontWeight.bold)))), + ]))), + if (_reportes.isNotEmpty) ...[ + const SizedBox(height: 16), + const Align(alignment: Alignment.centerLeft, + child: Text('Mis Reportes', style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.guindaPrimary, fontSize: 15))), + const SizedBox(height: 8), + ..._reportes.map((r) => Card(margin: const EdgeInsets.only(bottom: 6), + child: ListTile(dense: true, + leading: const CircleAvatar(backgroundColor: AppColors.guindaPrimary, radius: 16, + child: Icon(Icons.report, color: Colors.white, size: 16)), + title: Text(_tipos[r.tipo] ?? r.tipo, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)), + subtitle: Text(r.descripcion, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11)), + trailing: Container(padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + decoration: BoxDecoration(color: AppColors.naranjaAlerta.withOpacity(0.15), borderRadius: BorderRadius.circular(10)), + child: Text(r.estado, style: const TextStyle(fontSize: 9, color: AppColors.naranjaAlerta, fontWeight: FontWeight.bold)))))), + ], + ])), + ); + @override void dispose() { _desc.dispose(); super.dispose(); } +} diff --git a/lib/screens/driver/driver_home_screen.dart b/lib/screens/driver/driver_home_screen.dart new file mode 100644 index 0000000..f8e90ab --- /dev/null +++ b/lib/screens/driver/driver_home_screen.dart @@ -0,0 +1,456 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../core/app_colors.dart'; +import '../../services/auth_service.dart'; +import '../../services/route_simulator_service.dart'; +import '../../database/db_helper.dart'; +import '../../models/models.dart'; +import '../../data/routes_data.dart'; +import '../../widgets/route_map_widget.dart'; + +class DriverHomeScreen extends StatefulWidget { + const DriverHomeScreen({super.key}); + @override State createState() => _DriverHomeScreenState(); +} + +class _DriverHomeScreenState extends State { + int _tab = 0; + List _assignments = []; + String? _todayRouteId; + + @override void initState() { super.initState(); _load(); } + + Future _load() async { + final auth = context.read(); + if (auth.currentUser == null) return; + final list = await DbHelper.getAsignacionesByConductor(auth.currentUser!.id!); + final today = _todayDia(); + setState(() { + _assignments = list; + final match = list.where((a) => a.diaSemana == today); + _todayRouteId = match.isNotEmpty ? match.first.routeId : null; + }); + if (_todayRouteId != null) { + context.read().startRoute(_todayRouteId!); + } + } + + String _todayDia() { + const d = ['','LUNES','MARTES','MIERCOLES','JUEVES','VIERNES','SABADO','DOMINGO']; + return d[DateTime.now().weekday]; + } + + @override + Widget build(BuildContext context) { + final auth = context.watch(); + final sim = context.watch(); + final route = _todayRouteId != null ? getRouteById(_todayRouteId!) : null; + + // Solo notificaciones de la ruta actual del conductor + final lastNotif = _todayRouteId != null + ? sim.getNotificationForRoute(_todayRouteId!) : null; + + final tabs = [ + _DriverMainTab(auth:auth, sim:sim, route:route, + assignments:_assignments, todayRouteId:_todayRouteId, onRefresh:_load), + if (route != null) _DriverMapTab(route:route, sim:sim) + else const Center(child:Text('Sin ruta hoy')), + _DriverReportesTab(conductorId:auth.currentUser?.id, todayRouteId:_todayRouteId), + ]; + + return Scaffold( + body: Stack(children:[ + tabs[_tab], + if (lastNotif != null) + Positioned(top:MediaQuery.of(context).padding.top+8, left:0, right:0, + child:_NotifBanner(notif:lastNotif, + onDismiss:()=>sim.dismissRouteNotification(_todayRouteId??''))), + ]), + bottomNavigationBar: NavigationBar( + selectedIndex: _tab, + onDestinationSelected: (i) => setState(()=>_tab=i), + backgroundColor: Colors.white, + indicatorColor: AppColors.moradoConductor.withOpacity(0.15), + destinations: const [ + NavigationDestination(icon:Icon(Icons.dashboard_outlined), + selectedIcon:Icon(Icons.dashboard,color:AppColors.moradoConductor),label:'Mi Ruta'), + NavigationDestination(icon:Icon(Icons.map_outlined), + selectedIcon:Icon(Icons.map,color:AppColors.moradoConductor),label:'Mapa'), + NavigationDestination(icon:Icon(Icons.report_problem_outlined), + selectedIcon:Icon(Icons.report_problem,color:AppColors.moradoConductor),label:'Incidente'), + ], + ), + ); + } +} + +// ── Tab principal ───────────────────────────────────────────────────────── +class _DriverMainTab extends StatefulWidget { + final AuthService auth; final RouteSimulatorService sim; + final route; final assignments; final todayRouteId; final VoidCallback onRefresh; + const _DriverMainTab({required this.auth, required this.sim, required this.route, + required this.assignments, required this.todayRouteId, required this.onRefresh}); + @override State<_DriverMainTab> createState() => _DriverMainTabState(); +} + +class _DriverMainTabState extends State<_DriverMainTab> { + List _ciudadanoReportes = []; + + @override void initState() { super.initState(); _loadReportes(); } + + Future _loadReportes() async { + if (widget.todayRouteId == null) return; + final all = await DbHelper.getAllReportes(); + final filtered = all.where((r) => r.routeId == widget.todayRouteId).toList(); + if (mounted) setState(() => _ciudadanoReportes = filtered.take(5).toList()); + } + + @override + Widget build(BuildContext context) { + final posIdx = widget.todayRouteId != null + ? widget.sim.getPositionIndex(widget.todayRouteId!) : 0; + final gpsOk = widget.todayRouteId != null + ? widget.sim.isGpsActive(widget.todayRouteId!) : true; + + return CustomScrollView(slivers:[ + SliverAppBar(pinned:true, backgroundColor:AppColors.moradoConductor, foregroundColor:Colors.white, + bottom:PreferredSize(preferredSize:const Size.fromHeight(4), + child:Container(height:4,color:AppColors.dorado)), + title:Text('Conductor: ${widget.auth.currentUser?.nombre.split(' ').first ?? ''}', + style:const TextStyle(fontSize:16,fontWeight:FontWeight.bold)), + actions:[IconButton(icon:const Icon(Icons.logout), + onPressed:()async{ await widget.auth.logout(); + if(context.mounted) Navigator.pushReplacementNamed(context,'/login');})]), + + SliverPadding(padding:const EdgeInsets.all(14),sliver:SliverList(delegate:SliverChildListDelegate([ + // Ruta de hoy + Card(color:AppColors.moradoConductor.withOpacity(0.08), + shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(12), + side:BorderSide(color:AppColors.moradoConductor.withOpacity(0.3))), + child:Padding(padding:const EdgeInsets.all(14),child:Column( + crossAxisAlignment:CrossAxisAlignment.start, children:[ + Row(children:[ + const Icon(Icons.today,color:AppColors.moradoConductor), + const SizedBox(width:8), + Text('Hoy — ${_todayLabel()}', + style:const TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor,fontSize:15)), + ]), + const Divider(), + if (widget.route != null)...[ + Text(widget.route.name,style:const TextStyle(fontWeight:FontWeight.bold,fontSize:14)), + Text('Camión ${widget.route.truckId} • Turno: ${widget.route.turno}', + style:const TextStyle(color:AppColors.grisTexto,fontSize:12)), + const SizedBox(height:8), + Row(children:[ + Icon(gpsOk?Icons.gps_fixed:Icons.gps_off, + color:gpsOk?AppColors.verdeExito:AppColors.rojoError,size:16), + const SizedBox(width:4), + Text(gpsOk?'GPS Activo':'⚠️ GPS Desactivado', + style:TextStyle(color:gpsOk?AppColors.verdeExito:AppColors.rojoError, + fontWeight:FontWeight.bold,fontSize:12)), + const Spacer(), + Text('Posición ${posIdx+1}/8',style:const TextStyle(color:AppColors.grisTexto,fontSize:12)), + ]), + const SizedBox(height:8), + LinearProgressIndicator(value:(posIdx+1)/8, + backgroundColor:Colors.grey.shade300, + valueColor:const AlwaysStoppedAnimation(AppColors.moradoConductor)), + const SizedBox(height:6), + Text(widget.sim.getEtaText(widget.todayRouteId??''), + style:const TextStyle(fontSize:13,fontWeight:FontWeight.w500)), + ] else + const Text('⚠️ Sin ruta asignada hoy.',style:TextStyle(color:AppColors.rojoError)), + ]))), + const SizedBox(height:10), + + // Instrucciones + Card(child:Padding(padding:const EdgeInsets.all(12),child:Column( + crossAxisAlignment:CrossAxisAlignment.start, children:[ + const Text('📋 Instrucciones de Ruta', + style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor)), + const Divider(), + const Text('• Sigue la ruta asignada sin desviaciones\n' + '• Mantén el GPS activo en todo momento\n' + '• Reporta incidentes desde "Incidente"\n' + '• Si hay problema, el admin decidirá si se cancela o retrasa', + style:TextStyle(fontSize:12,color:AppColors.grisTexto)), + ]))), + const SizedBox(height:10), + + // Reportes ciudadanos de SU ruta + if (_ciudadanoReportes.isNotEmpty) ...[ + Card(color:Colors.orange.shade50, + shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(10), + side:BorderSide(color:Colors.orange.shade200)), + child:Padding(padding:const EdgeInsets.all(12),child:Column( + crossAxisAlignment:CrossAxisAlignment.start, children:[ + const Row(children:[ + Icon(Icons.people,color:AppColors.naranjaAlerta,size:16), + SizedBox(width:6), + Text('Reportes de tu ruta hoy', + style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.naranjaAlerta,fontSize:13)), + ]), + const Divider(), + ..._ciudadanoReportes.map((r)=>Padding( + padding:const EdgeInsets.symmetric(vertical:3), + child:Row(children:[ + const Icon(Icons.person_outline,size:12,color:AppColors.grisTexto), + const SizedBox(width:4), + Expanded(child:Text(r.descripcion,style:const TextStyle(fontSize:11), + maxLines:1,overflow:TextOverflow.ellipsis)), + ]))), + ]))), + const SizedBox(height:10), + ], + + // Horario LMV / MJS + Card(child:Padding(padding:const EdgeInsets.all(12),child:Column( + crossAxisAlignment:CrossAxisAlignment.start, children:[ + const Text('Mi Horario', + style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor)), + const Divider(), + if (widget.assignments.isEmpty) + const Text('Sin asignaciones. Contacta al administrador.', + style:TextStyle(color:AppColors.grisTexto,fontSize:12)) + else ...[ + _scheduleGroup(widget.assignments,'LUNES','MIERCOLES','VIERNES', + 'Lunes, Miércoles y Viernes'), + const SizedBox(height:8), + _scheduleGroup(widget.assignments,'MARTES','JUEVES','SABADO', + 'Martes, Jueves y Sábado'), + ], + ]))), + const SizedBox(height:80), + ]))), + ]); + } + + Widget _scheduleGroup(List all, String d1, String d2, String d3, String label) { + AssignmentModel? found; + for (final dia in [d1,d2,d3]) { + try { found = all.firstWhere((a)=>a.diaSemana==dia); break; } catch(_){} + } + return Container(padding:const EdgeInsets.all(10), + decoration:BoxDecoration(color:AppColors.moradoConductor.withOpacity(0.06), + borderRadius:BorderRadius.circular(8), + border:Border.all(color:AppColors.moradoConductor.withOpacity(0.2))), + child:Row(children:[ + const Icon(Icons.calendar_today,size:14,color:AppColors.moradoConductor), + const SizedBox(width:6), + Expanded(child:Text(label,style:const TextStyle(fontWeight:FontWeight.w600,fontSize:12))), + if (found!=null) + Container(padding:const EdgeInsets.symmetric(horizontal:8,vertical:3), + decoration:BoxDecoration(color:AppColors.moradoConductor,borderRadius:BorderRadius.circular(8)), + child:Text('${found.routeId} • ${found.turno}', + style:const TextStyle(fontSize:11,color:Colors.white,fontWeight:FontWeight.bold))) + else + const Text('Sin asignar',style:TextStyle(fontSize:11,color:AppColors.grisTexto)), + ])); + } + + String _todayLabel() { + const d=['','Lunes','Martes','Miércoles','Jueves','Viernes','Sábado','Domingo']; + return d[DateTime.now().weekday]; + } +} + +// ── Tab mapa ────────────────────────────────────────────────────────────── +class _DriverMapTab extends StatelessWidget { + final route; final sim; + const _DriverMapTab({required this.route, required this.sim}); + @override + Widget build(BuildContext context) => Scaffold( + appBar:AppBar(automaticallyImplyLeading:false, + backgroundColor:AppColors.moradoConductor,foregroundColor:Colors.white, + title:Text(route.name,style:const TextStyle(fontSize:13)), + bottom:PreferredSize(preferredSize:const Size.fromHeight(4), + child:Container(height:4,color:AppColors.dorado))), + body:RouteMapWidget(route:route,simulator:sim, + height:MediaQuery.of(context).size.height,showFullRoute:true)); +} + +// ── Tab reporte incidente — usa routeId actual ──────────────────────────── +class _DriverReportesTab extends StatefulWidget { + final int? conductorId; + final String? todayRouteId; // Ruta actual del conductor + const _DriverReportesTab({required this.conductorId, required this.todayRouteId}); + @override State<_DriverReportesTab> createState() => _DriverReportesTabState(); +} + +class _DriverReportesTabState extends State<_DriverReportesTab> { + String _tipo = 'INCIDENTE_LLANTA'; + final _desc = TextEditingController(); + bool _loading = false, _sent = false; + List _misIncidentes = []; + + static const _tipos = { + 'INCIDENTE_LLANTA': '🔧 Llanta ponchada', + 'INCIDENTE_MECANICA': '🔥 Falla mecánica', + 'INCIDENTE_ACCIDENTE': '🚑 Accidente', + 'INCIDENTE_CAMINO': '🚧 Camino bloqueado', + 'INCIDENTE_COMBUSTIBLE':'⛽ Sin combustible', + 'INCIDENTE_OTRO': '📝 Otro', + }; + + @override void initState() { super.initState(); _load(); } + + Future _load() async { + final all = await DbHelper.getAlertas(); + // Solo incidentes de la ruta actual del conductor + final mine = all.where((a) => + a.tipo.startsWith('INCIDENTE_') && + a.routeId == (widget.todayRouteId ?? '')).toList(); + if (mounted) setState(() => _misIncidentes = mine); + } + + Future _enviar() async { + if (widget.todayRouteId == null) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content:Text('No tienes ruta asignada hoy'), + backgroundColor:AppColors.rojoError)); return; + } + if (_desc.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content:Text('Describe el incidente'),backgroundColor:AppColors.rojoError)); return; + } + setState(()=>_loading=true); + // Guardar el incidente asociado a la RUTA ACTUAL + await DbHelper.insertAlerta(AlertaModel( + tipo: _tipo, + routeId: widget.todayRouteId!, // ← ID de la ruta actual, no del conductor + mensaje: '${_tipos[_tipo]}: ${_desc.text.trim()}', + fecha: DateTime.now().toIso8601String(), + )); + await _load(); + if (!mounted) return; + setState(() { _loading=false; _sent=true; }); + _desc.clear(); + await Future.delayed(const Duration(seconds:2)); + if (mounted) setState(()=>_sent=false); + } + + @override + Widget build(BuildContext context) => Scaffold( + backgroundColor:AppColors.grisFondo, + appBar:AppBar(automaticallyImplyLeading:false, + backgroundColor:AppColors.moradoConductor,foregroundColor:Colors.white, + title:const Text('Reportar Incidente'), + bottom:PreferredSize(preferredSize:const Size.fromHeight(4), + child:Container(height:4,color:AppColors.dorado))), + body: _sent + ? const Center(child:Column(mainAxisAlignment:MainAxisAlignment.center,children:[ + Icon(Icons.check_circle,color:AppColors.verdeExito,size:64), + SizedBox(height:12), + Text('¡Incidente reportado!',style:TextStyle(fontSize:18,fontWeight:FontWeight.bold,color:AppColors.verdeExito)), + Text('El administrador será notificado.',style:TextStyle(color:AppColors.grisTexto)), + ])) + : SingleChildScrollView(padding:const EdgeInsets.all(16),child:Column(children:[ + // Info ruta actual + if (widget.todayRouteId != null) + Container(margin:const EdgeInsets.only(bottom:12), + padding:const EdgeInsets.all(10), + decoration:BoxDecoration(color:AppColors.moradoConductor.withOpacity(0.08), + borderRadius:BorderRadius.circular(8), + border:Border.all(color:AppColors.moradoConductor.withOpacity(0.3))), + child:Row(children:[ + const Icon(Icons.route,color:AppColors.moradoConductor,size:16), + const SizedBox(width:6), + Text('Incidente en: ${widget.todayRouteId}', + style:const TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor,fontSize:13)), + ])) + else + Container(margin:const EdgeInsets.only(bottom:12), + padding:const EdgeInsets.all(10), + decoration:BoxDecoration(color:Colors.orange.shade50,borderRadius:BorderRadius.circular(8)), + child:const Text('⚠️ No tienes ruta asignada hoy', + style:TextStyle(color:AppColors.naranjaAlerta,fontWeight:FontWeight.bold))), + + Card(shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(12)), + child:Padding(padding:const EdgeInsets.all(16),child:Column( + crossAxisAlignment:CrossAxisAlignment.start, children:[ + const Text('Tipo de incidente', + style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor,fontSize:15)), + const SizedBox(height:8), + ..._tipos.entries.map((e)=>RadioListTile(dense:true, + value:e.key,groupValue:_tipo, + title:Text(e.value,style:const TextStyle(fontSize:13)), + activeColor:AppColors.moradoConductor, + onChanged:(v)=>setState(()=>_tipo=v!))), + const SizedBox(height:10), + const Text('Descripción',style:TextStyle(fontWeight:FontWeight.w600,fontSize:13)), + const SizedBox(height:6), + TextField(controller:_desc,maxLines:3, + decoration:const InputDecoration(hintText:'Describe qué pasó...', + border:OutlineInputBorder(),filled:true,fillColor:Colors.white)), + const SizedBox(height:12), + Container(padding:const EdgeInsets.all(10), + decoration:BoxDecoration(color:Colors.orange.shade50, + borderRadius:BorderRadius.circular(8), + border:Border.all(color:Colors.orange.shade200)), + child:const Text( + '⚠️ El administrador verá este incidente en tu ruta actual ' + 'y decidirá si continúa, se retrasa o se cancela.', + style:TextStyle(fontSize:11,color:Colors.black87))), + const SizedBox(height:14), + SizedBox(width:double.infinity,height:48, + child:ElevatedButton.icon( + onPressed:(_loading||widget.todayRouteId==null)?null:_enviar, + style:ElevatedButton.styleFrom(backgroundColor:AppColors.moradoConductor, + foregroundColor:Colors.white, + shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(8))), + icon:_loading?const SizedBox(width:18,height:18, + child:CircularProgressIndicator(color:Colors.white,strokeWidth:2)) + :const Icon(Icons.send), + label:const Text('ENVIAR INCIDENTE',style:TextStyle(fontWeight:FontWeight.bold)))), + ]))), + + if (_misIncidentes.isNotEmpty)...[ + const SizedBox(height:16), + const Align(alignment:Alignment.centerLeft, + child:Text('Mis incidentes de hoy', + style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor,fontSize:14))), + const SizedBox(height:8), + ..._misIncidentes.take(5).map((a)=>Card(margin:const EdgeInsets.only(bottom:6), + child:ListTile(dense:true, + leading:CircleAvatar(backgroundColor:AppColors.moradoConductor,radius:16, + child:const Icon(Icons.warning,color:Colors.white,size:14)), + title:Text(_tipos[a.tipo]??a.tipo, + style:const TextStyle(fontSize:12,fontWeight:FontWeight.w600)), + subtitle:Text(a.mensaje,maxLines:1,overflow:TextOverflow.ellipsis, + style:const TextStyle(fontSize:11)), + trailing:Icon(a.resuelta?Icons.check_circle:Icons.pending, + color:a.resuelta?AppColors.verdeExito:AppColors.naranjaAlerta,size:18)))), + ], + ])), + ); + + @override void dispose(){ _desc.dispose(); super.dispose(); } +} + +// ── Notif banner conductor ──────────────────────────────────────────────── +class _NotifBanner extends StatelessWidget { + final AppNotification notif; final VoidCallback onDismiss; + const _NotifBanner({required this.notif, required this.onDismiss}); + @override + Widget build(BuildContext context) { + final color = notif.event==NotifEvent.gpsLost?Colors.red.shade800 + :notif.event==NotifEvent.truckStopped?AppColors.naranjaAlerta + :notif.event==NotifEvent.routeCancelled?AppColors.rojoError + :AppColors.moradoConductor; + return Material(color:Colors.transparent, + child:Container(margin:const EdgeInsets.all(10), + decoration:BoxDecoration(color:color,borderRadius:BorderRadius.circular(12), + boxShadow:const[BoxShadow(color:Colors.black26,blurRadius:6)]), + child:Padding(padding:const EdgeInsets.all(12),child:Row(children:[ + const Icon(Icons.notification_important,color:Colors.white,size:22), + const SizedBox(width:8), + Expanded(child:Column(crossAxisAlignment:CrossAxisAlignment.start, + mainAxisSize:MainAxisSize.min,children:[ + Text(notif.title,style:const TextStyle(color:Colors.white,fontWeight:FontWeight.bold,fontSize:13)), + Text(notif.body,style:const TextStyle(color:Colors.white70,fontSize:11), + maxLines:2,overflow:TextOverflow.ellipsis), + ])), + IconButton(icon:const Icon(Icons.close,color:Colors.white,size:18),onPressed:onDismiss), + ])))); + } +} diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart new file mode 100644 index 0000000..e2e335d --- /dev/null +++ b/lib/screens/login_screen.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../core/app_colors.dart'; +import '../services/auth_service.dart'; + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + @override State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _emailCtrl = TextEditingController(); + final _passCtrl = TextEditingController(); + bool _loading = false, _obscure = true; + + Future _login() async { + if (_emailCtrl.text.isEmpty || _passCtrl.text.isEmpty) { + _snack('Llena todos los campos', isError: true); return; + } + setState(() => _loading = true); + final err = await context.read().login(_emailCtrl.text, _passCtrl.text); + if (!mounted) return; + setState(() => _loading = false); + if (err != null) { _snack(err, isError: true); return; } + final rol = context.read().rol; + switch (rol) { + case 'ADMINISTRADOR': Navigator.pushReplacementNamed(context, '/admin'); break; + case 'CONDUCTOR': Navigator.pushReplacementNamed(context, '/driver'); break; + default: Navigator.pushReplacementNamed(context, '/home'); break; + } + } + + void _snack(String msg, {bool isError = false}) => ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content:Text(msg), + backgroundColor: isError ? AppColors.rojoError : AppColors.verdeExito)); + + @override + Widget build(BuildContext context) => Scaffold( + backgroundColor: AppColors.grisFondo, + body: SingleChildScrollView(child: Column(children: [ + Container(width:double.infinity, color:AppColors.guindaPrimary, + padding:const EdgeInsets.only(top:60,bottom:28), + child:Column(children:[ + Container(width:84,height:84, + decoration:BoxDecoration(color:Colors.white12,shape:BoxShape.circle, + border:Border.all(color:AppColors.dorado,width:2.5)), + child:const Icon(Icons.delete_sweep_rounded,size:44,color:AppColors.dorado)), + const SizedBox(height:14), + const Text('H. AYUNTAMIENTO DE CELAYA', + style:TextStyle(color:Colors.white,fontSize:13,fontWeight:FontWeight.bold,letterSpacing:1.2)), + const SizedBox(height:4), + const Text('Sistema de Recolección de Residuos', + style:TextStyle(color:AppColors.dorado,fontSize:13)), + ])), + Container(height:4,color:AppColors.dorado), + Padding(padding:const EdgeInsets.all(24), child:Card(elevation:4, + shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(12)), + child:Padding(padding:const EdgeInsets.all(24), child:Column( + crossAxisAlignment:CrossAxisAlignment.start, children:[ + const Text('Iniciar Sesión',style:TextStyle(fontSize:20, + fontWeight:FontWeight.bold,color:AppColors.guindaPrimary)), + const SizedBox(height:16), + // Accesos rápidos demo + Container(padding:const EdgeInsets.all(10), + decoration:BoxDecoration(color:Colors.blue.shade50,borderRadius:BorderRadius.circular(8)), + child:const Column(crossAxisAlignment:CrossAxisAlignment.start, children:[ + Text('Demo rápido:',style:TextStyle(fontWeight:FontWeight.bold,fontSize:12,color:AppColors.azulInfo)), + Text('Admin: admin@celaya.gob.mx / admin123',style:TextStyle(fontSize:11)), + Text('Conductor: conductor@celaya.gob.mx / conductor123',style:TextStyle(fontSize:11)), + ])), + const SizedBox(height:16), + TextField(controller:_emailCtrl,keyboardType:TextInputType.emailAddress, + decoration:const InputDecoration(labelText:'Correo electrónico', + prefixIcon:Icon(Icons.email_outlined,color:AppColors.guindaPrimary), + border:OutlineInputBorder())), + const SizedBox(height:12), + TextField(controller:_passCtrl,obscureText:_obscure, + decoration:InputDecoration(labelText:'Contraseña', + prefixIcon:const Icon(Icons.lock_outline,color:AppColors.guindaPrimary), + border:const OutlineInputBorder(), + suffixIcon:IconButton(icon:Icon(_obscure?Icons.visibility_off:Icons.visibility), + onPressed:()=>setState(()=>_obscure=!_obscure)))), + const SizedBox(height:20), + SizedBox(width:double.infinity,height:50, + child:ElevatedButton(onPressed:_loading?null:_login, + style:ElevatedButton.styleFrom(backgroundColor:AppColors.guindaPrimary, + foregroundColor:Colors.white,shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(8))), + child:_loading?const CircularProgressIndicator(color:Colors.white,strokeWidth:2) + :const Text('ENTRAR',style:TextStyle(fontWeight:FontWeight.bold,letterSpacing:1)))), + const SizedBox(height:12), + const Divider(), + const SizedBox(height:12), + SizedBox(width:double.infinity,height:50, + child:OutlinedButton(onPressed:()=>Navigator.pushNamed(context,'/register'), + style:OutlinedButton.styleFrom(foregroundColor:AppColors.guindaPrimary, + side:const BorderSide(color:AppColors.guindaPrimary), + shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(8))), + child:const Text('CREAR CUENTA CIUDADANO',style:TextStyle(fontWeight:FontWeight.bold)))), + ])))), + const Padding(padding:EdgeInsets.only(bottom:20), + child:Text('Gobierno Municipal de Celaya • Guanajuato', + style:TextStyle(color:AppColors.grisTexto,fontSize:11))), + ])), + ); + @override void dispose() { _emailCtrl.dispose(); _passCtrl.dispose(); super.dispose(); } +} diff --git a/lib/screens/register_screen.dart b/lib/screens/register_screen.dart new file mode 100644 index 0000000..570b6c7 --- /dev/null +++ b/lib/screens/register_screen.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../core/app_colors.dart'; +import '../data/colonies_data.dart'; +import '../models/route_model.dart'; +import '../services/auth_service.dart'; + +class RegisterScreen extends StatefulWidget { + const RegisterScreen({super.key}); + @override State createState() => _RegisterScreenState(); +} + +class _RegisterScreenState extends State { + final _nombre = TextEditingController(); + final _email = TextEditingController(); + final _pass = TextEditingController(); + final _calle = TextEditingController(); + ColonyModel? _colony; + bool _loading = false, _obscure = true; + + Future _register() async { + if ([_nombre,_email,_pass,_calle].any((c)=>c.text.trim().isEmpty) || _colony==null) { + _snack('Completa todos los campos', isError:true); return; } + if (_pass.text.length < 6) { _snack('Contraseña mínimo 6 caracteres', isError:true); return; } + setState(()=>_loading=true); + final err = await context.read().register( + nombre:_nombre.text, email:_email.text, password:_pass.text, + calle:_calle.text, colonia:_colony!.colonia, + routeId:_colony!.routeId, horarioEstimado:_colony!.horarioEstimado); + if (!mounted) return; + setState(()=>_loading=false); + if (err!=null) { _snack(err,isError:true); return; } + Navigator.pushReplacementNamed(context, '/home'); + } + + void _snack(String msg,{bool isError=false}) => ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content:Text(msg), + backgroundColor:isError?AppColors.rojoError:AppColors.verdeExito)); + + @override + Widget build(BuildContext context) => Scaffold( + backgroundColor: AppColors.grisFondo, + appBar: AppBar(backgroundColor:AppColors.guindaPrimary,foregroundColor:Colors.white, + title:const Text('Registro Ciudadano'), + bottom:PreferredSize(preferredSize:const Size.fromHeight(4), + child:Container(height:4,color:AppColors.dorado))), + body: SingleChildScrollView(padding:const EdgeInsets.all(20), child:Column(children:[ + _field(_nombre,'Nombre completo',Icons.badge_outlined), + const SizedBox(height:12), + _field(_email,'Correo electrónico',Icons.email_outlined,type:TextInputType.emailAddress), + const SizedBox(height:12), + TextField(controller:_pass,obscureText:_obscure, + decoration:InputDecoration(labelText:'Contraseña (mín. 6)', + prefixIcon:const Icon(Icons.lock_outline,color:AppColors.guindaPrimary), + border:const OutlineInputBorder(),filled:true,fillColor:Colors.white, + suffixIcon:IconButton(icon:Icon(_obscure?Icons.visibility_off:Icons.visibility), + onPressed:()=>setState(()=>_obscure=!_obscure)))), + const SizedBox(height:20), + const Align(alignment:Alignment.centerLeft, + child:Text('Domicilio',style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.guindaPrimary,fontSize:16))), + const SizedBox(height:10), + _field(_calle,'Calle y número',Icons.signpost_outlined), + const SizedBox(height:12), + DropdownButtonFormField( + decoration:const InputDecoration(labelText:'Colonia', + prefixIcon:Icon(Icons.location_city,color:AppColors.guindaPrimary), + border:OutlineInputBorder(),filled:true,fillColor:Colors.white), + hint:const Text('Selecciona tu colonia'), + value:_colony?.colonia, isExpanded:true, + items:colonyNames.map((n)=>DropdownMenuItem(value:n,child:Text(n,style:const TextStyle(fontSize:13)))).toList(), + onChanged:(v){ if(v!=null) setState(()=>_colony=getColonyByName(v)); }), + if (_colony!=null) ...[ + const SizedBox(height:10), + Container(padding:const EdgeInsets.all(12), + decoration:BoxDecoration(color:AppColors.guindaPrimary.withOpacity(0.08), + borderRadius:BorderRadius.circular(8), + border:Border.all(color:AppColors.guindaPrimary.withOpacity(0.3))), + child:Column(crossAxisAlignment:CrossAxisAlignment.start,children:[ + Text('Ruta: ${_colony!.routeId}',style:const TextStyle(color:AppColors.guindaPrimary,fontWeight:FontWeight.bold)), + Text('Horario: ${_colony!.horarioEstimado}',style:const TextStyle(color:AppColors.grisTexto,fontSize:12)), + ]))], + const SizedBox(height:24), + SizedBox(width:double.infinity,height:50, + child:ElevatedButton(onPressed:_loading?null:_register, + style:ElevatedButton.styleFrom(backgroundColor:AppColors.guindaPrimary, + foregroundColor:Colors.white,shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(8))), + child:_loading?const CircularProgressIndicator(color:Colors.white,strokeWidth:2) + :const Text('REGISTRARME',style:TextStyle(fontWeight:FontWeight.bold,letterSpacing:1)))), + const SizedBox(height:20), + ])), + ); + + Widget _field(TextEditingController c, String label, IconData icon, + {TextInputType type=TextInputType.text}) => + TextField(controller:c,keyboardType:type, + decoration:InputDecoration(labelText:label, + prefixIcon:Icon(icon,color:AppColors.guindaPrimary), + border:const OutlineInputBorder(),filled:true,fillColor:Colors.white)); + + @override void dispose(){ _nombre.dispose();_email.dispose();_pass.dispose();_calle.dispose();super.dispose(); } +} diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart new file mode 100644 index 0000000..d530fdc --- /dev/null +++ b/lib/screens/splash_screen.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../core/app_colors.dart'; +import '../services/auth_service.dart'; +import '../services/route_simulator_service.dart'; + +class SplashScreen extends StatefulWidget { + const SplashScreen({super.key}); + @override State createState() => _SplashScreenState(); +} + +class _SplashScreenState extends State { + @override + void initState() { super.initState(); _go(); } + + Future _go() async { + await Future.delayed(const Duration(seconds: 2)); + if (!mounted) return; + final auth = context.read(); + context.read().startAllRoutes(); + if (auth.isLoggedIn) { + _navigate(auth.rol); + } else { + Navigator.pushReplacementNamed(context, '/login'); + } + } + + void _navigate(String rol) { + switch (rol) { + case 'ADMINISTRADOR': Navigator.pushReplacementNamed(context, '/admin'); break; + case 'CONDUCTOR': Navigator.pushReplacementNamed(context, '/driver'); break; + default: Navigator.pushReplacementNamed(context, '/home'); break; + } + } + + @override + Widget build(BuildContext context) => Scaffold( + backgroundColor: AppColors.guindaPrimary, + body: Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Container(width:100,height:100, + decoration:BoxDecoration(color:Colors.white12,shape:BoxShape.circle, + border:Border.all(color:AppColors.dorado,width:3)), + child:const Icon(Icons.delete_sweep_rounded,size:52,color:AppColors.dorado)), + const SizedBox(height:20), + const Text('CELAYA LIMPIA',style:TextStyle(color:Colors.white,fontSize:26, + fontWeight:FontWeight.bold,letterSpacing:2)), + const SizedBox(height:4), + const Text('H. Ayuntamiento de Celaya',style:TextStyle(color:Colors.white60,fontSize:13)), + const SizedBox(height:40), + const CircularProgressIndicator(valueColor:AlwaysStoppedAnimation(AppColors.dorado)), + ])), + ); +} diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart new file mode 100644 index 0000000..305c93b --- /dev/null +++ b/lib/services/auth_service.dart @@ -0,0 +1,70 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../models/models.dart'; +import '../database/db_helper.dart'; + +class AuthService extends ChangeNotifier { + UserModel? _user; + DomicilioModel? _domicilio; + bool _loading = true; + + UserModel? get currentUser => _user; + DomicilioModel? get primaryDomicilio => _domicilio; + bool get isLoggedIn => _user != null; + bool get loading => _loading; + String get rol => _user?.rol ?? ''; + + AuthService() { _checkSession(); } + + Future _checkSession() async { + final p = await SharedPreferences.getInstance(); + final id = p.getInt('user_id'); + if (id != null) { + _user = await DbHelper.getUserById(id); + if (_user?.rol == 'CIUDADANO') { + _domicilio = await DbHelper.getPrimaryDomicilio(id); + } + } + _loading = false; + notifyListeners(); + } + + Future login(String email, String password) async { + final user = await DbHelper.getUserByEmail(email.trim().toLowerCase()); + if (user == null) return 'Correo no registrado'; + if (user.password != password) return 'Contraseña incorrecta'; + _user = user; + if (user.rol == 'CIUDADANO') { + _domicilio = await DbHelper.getPrimaryDomicilio(user.id!); + } + final p = await SharedPreferences.getInstance(); + await p.setInt('user_id', user.id!); + notifyListeners(); + return null; + } + + Future register({required String nombre, required String email, + required String password, required String calle, required String colonia, + required String routeId, required String horarioEstimado}) async { + final ex = await DbHelper.getUserByEmail(email.trim().toLowerCase()); + if (ex != null) return 'Correo ya registrado'; + final user = UserModel(nombre:nombre.trim(), + email:email.trim().toLowerCase(), password:password, rol:'CIUDADANO'); + final uid = await DbHelper.insertUser(user); + await DbHelper.insertDomicilio(DomicilioModel(userId:uid, calle:calle.trim(), + colonia:colonia, routeId:routeId, horarioEstimado:horarioEstimado)); + _user = await DbHelper.getUserById(uid); + _domicilio = await DbHelper.getPrimaryDomicilio(uid); + final p = await SharedPreferences.getInstance(); + await p.setInt('user_id', uid); + notifyListeners(); + return null; + } + + Future logout() async { + _user = null; _domicilio = null; + final p = await SharedPreferences.getInstance(); + await p.remove('user_id'); + notifyListeners(); + } +} diff --git a/lib/services/route_simulator_service.dart b/lib/services/route_simulator_service.dart new file mode 100644 index 0000000..6ff1c5f --- /dev/null +++ b/lib/services/route_simulator_service.dart @@ -0,0 +1,204 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import '../models/route_model.dart'; +import '../models/models.dart'; +import '../data/routes_data.dart'; +import '../database/db_helper.dart'; + +enum NotifEvent { routeStart, truckProximity, routeCompleted, gpsLost, truckStopped, routeCancelled, none } + +class AppNotification { + final NotifEvent event; + final String title; + final String body; + final String routeId; // Para filtrar por usuario + final DateTime timestamp; + AppNotification({required this.event, required this.title, + required this.body, required this.routeId}) + : timestamp = DateTime.now(); +} + +class SimulatorState { + final String routeId; + int positionIndex; + bool gpsActive; + DateTime lastMoved; + bool stoppedAlertSent; + SimulatorState({required this.routeId, this.positionIndex = 0, + this.gpsActive = true, required this.lastMoved, this.stoppedAlertSent = false}); +} + +class RouteSimulatorService extends ChangeNotifier { + final Map _states = {}; + Timer? _globalTimer; + Timer? _gpsMonitorTimer; + + AppNotification? _lastNotification; // Admin ve todas + final List _history = []; + + // ── Getters ───────────────────────────────────────────────────────────── + // Admin: ve la última notificación global + AppNotification? get lastNotification => _lastNotification; + + // Ciudadano/Conductor: solo ve notificaciones de SU ruta + AppNotification? getNotificationForRoute(String routeId) { + if (_lastNotification?.routeId == routeId) return _lastNotification; + return null; + } + + List get history => List.unmodifiable(_history); + + // Historial filtrado por ruta + List historyForRoute(String routeId) => + _history.where((n) => n.routeId == routeId).toList(); + + // ── Inicio ─────────────────────────────────────────────────────────────── + void startAllRoutes() { + for (final r in routesData) { + _states[r.routeId] = SimulatorState(routeId: r.routeId, lastMoved: DateTime.now()); + } + _globalTimer?.cancel(); + _globalTimer = Timer.periodic(const Duration(seconds: 30), (_) => _tick()); + _gpsMonitorTimer?.cancel(); + _gpsMonitorTimer = Timer.periodic(const Duration(minutes: 5), (_) => _monitorGps()); + notifyListeners(); + } + + void startRoute(String routeId) { + _states[routeId] = SimulatorState(routeId: routeId, lastMoved: DateTime.now()); + _globalTimer ??= Timer.periodic(const Duration(seconds: 30), (_) => _tick()); + notifyListeners(); + } + + void _tick() { + bool changed = false; + for (final entry in _states.entries) { + final state = entry.value; + final route = getRouteById(state.routeId); + if (route == null || !state.gpsActive) continue; + if (state.positionIndex < route.positions.length - 1) { + state.positionIndex++; + state.lastMoved = DateTime.now(); + _checkNotification(state, route); + changed = true; + } + } + if (changed) notifyListeners(); + } + + void _monitorGps() { + for (final state in _states.values) { + if (!state.gpsActive) continue; + final diff = DateTime.now().difference(state.lastMoved); + if (diff.inMinutes >= 30 && !state.stoppedAlertSent) { + state.stoppedAlertSent = true; + _fireAndSaveAlert( + event: NotifEvent.truckStopped, routeId: state.routeId, + title: '⚠️ Camión detenido', + body: 'El camión ${state.routeId} lleva +30 min sin moverse.', + tipo: 'CAMION_DETENIDO', + ); + } + } + } + + void _checkNotification(SimulatorState state, RouteModel route) { + if (state.positionIndex == 1) { + _fireNotif(NotifEvent.routeStart, '¡Ruta Iniciada! 🚛', + 'El camión ha salido del Relleno Sanitario rumbo a tu sector.', state.routeId); + } else if (state.positionIndex == 3) { + _fireNotif(NotifEvent.truckProximity, 'Camión Cercano ⚠️', + 'El camión está a menos de 15 minutos. ¡Saca tus bolsas!', state.routeId); + } else if (state.positionIndex == route.positions.length - 1) { + _fireNotif(NotifEvent.routeCompleted, 'Servicio Finalizado 🏁', + 'El camión de ${state.routeId} concluyó su jornada.', state.routeId); + } + } + + void _fireNotif(NotifEvent event, String title, String body, String routeId) { + final n = AppNotification(event: event, title: title, body: body, routeId: routeId); + _lastNotification = n; + _history.insert(0, n); + notifyListeners(); + } + + Future _fireAndSaveAlert({required NotifEvent event, required String routeId, + required String title, required String body, required String tipo}) async { + _fireNotif(event, title, body, routeId); + await DbHelper.insertAlerta(AlertaModel( + tipo: tipo, routeId: routeId, mensaje: body, + fecha: DateTime.now().toIso8601String())); + } + + // Notificación manual (admin cancela, retrasa ruta, etc.) + void fireCustomNotification(String title, String body, String routeId, NotifEvent event) { + _fireNotif(event, title, body, routeId); + } + + // ── GPS ────────────────────────────────────────────────────────────────── + Future simulateGpsLost(String routeId) async { + final state = _states[routeId]; + if (state == null) return; + state.gpsActive = false; + await _fireAndSaveAlert( + event: NotifEvent.gpsLost, routeId: routeId, + title: '📡 GPS Desactivado', + body: 'Se perdió la señal GPS del camión $routeId.', + tipo: 'GPS_PERDIDO', + ); + notifyListeners(); + } + + void restoreGps(String routeId) { + final state = _states[routeId]; + if (state == null) return; + state.gpsActive = true; + state.stoppedAlertSent = false; + notifyListeners(); + } + + // ── Getters de estado ──────────────────────────────────────────────────── + SimulatorState? getState(String routeId) => _states[routeId]; + int getPositionIndex(String routeId) => _states[routeId]?.positionIndex ?? 0; + bool isTruckClose(String routeId) => getPositionIndex(routeId) >= 3; + bool isGpsActive(String routeId) => _states[routeId]?.gpsActive ?? true; + + String getEtaText(String routeId) { + final state = _states[routeId]; + if (state == null) return 'Sin información'; + if (!state.gpsActive) return '📡 Señal GPS perdida'; + final route = getRouteById(routeId); + if (route == null) return 'Ruta no encontrada'; + final idx = state.positionIndex; + if (idx >= route.positions.length) return '✅ Servicio finalizado'; + switch (idx) { + case 0: return '🕐 Ruta por iniciar'; + case 1: return '🚛 Camión en camino'; + case 2: return '🚛 Aprox. 30 min para llegar'; + case 3: return '⚠️ Menos de 15 min — ¡Saca tus bolsas!'; + case 4: return '🔔 El camión está en tu zona'; + case 5: return '✅ Pasando por tu colonia'; + case 6: return '↩️ Regresando al relleno'; + default: return '🏁 Servicio del día finalizado'; + } + } + + void dismissNotification() { + _lastNotification = null; + notifyListeners(); + } + + void dismissRouteNotification(String routeId) { + if (_lastNotification?.routeId == routeId) { + _lastNotification = null; + notifyListeners(); + } + } + + @override + void dispose() { + _globalTimer?.cancel(); + _gpsMonitorTimer?.cancel(); + super.dispose(); + } +} diff --git a/lib/widgets/route_map_widget.dart b/lib/widgets/route_map_widget.dart new file mode 100644 index 0000000..31f6502 --- /dev/null +++ b/lib/widgets/route_map_widget.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import '../core/app_colors.dart'; +import '../models/route_model.dart'; +import '../services/route_simulator_service.dart'; + +List _smooth(List pts) { + if (pts.length < 2) return pts; + final res = []; + for (int i = 0; i < pts.length - 1; i++) { + final p0 = pts[i > 0 ? i - 1 : i]; + final p1 = pts[i]; + final p2 = pts[i + 1]; + final p3 = pts[i + 2 < pts.length ? i + 2 : i + 1]; + res.add(p1); + for (int j = 1; j <= 8; j++) { + final t = j / 9.0; + res.add(LatLng(_cr(p0.latitude, p1.latitude, p2.latitude, p3.latitude, t), + _cr(p0.longitude, p1.longitude, p2.longitude, p3.longitude, t))); + } + } + res.add(pts.last); + return res; +} + +double _cr(double a, double b, double c, double d, double t) => 0.5 * ( + (2*b) + (-a+c)*t + (2*a-5*b+4*c-d)*t*t + (-a+3*b-3*c+d)*t*t*t); + +class RouteMapWidget extends StatelessWidget { + final RouteModel route; + final RouteSimulatorService simulator; + final bool showFullRoute; + final double height; + const RouteMapWidget({super.key, required this.route, required this.simulator, + this.showFullRoute = false, this.height = 300}); + + @override + Widget build(BuildContext context) { + final posIdx = simulator.getPositionIndex(route.routeId); + final cur = posIdx < route.positions.length + ? route.positions[posIdx].latLng : route.positions.last.latLng; + final gps = simulator.isGpsActive(route.routeId); + final all = _smooth(route.polylinePoints); + final done = posIdx > 0 + ? _smooth(route.positions.take(posIdx+1).map((p) => p.latLng).toList()) + : []; + + return ClipRRect(borderRadius: BorderRadius.circular(12), + child: SizedBox(height: height, + child: FlutterMap( + options: MapOptions(initialCenter: cur, initialZoom: 14), + children: [ + TileLayer(urlTemplate:'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName:'com.example.celaya_limpia'), + PolylineLayer(polylines:[ + Polyline(points:all, color:Colors.grey.withOpacity(0.4), strokeWidth:4, + borderColor:Colors.white54, borderStrokeWidth:1), + ]), + if (done.isNotEmpty) PolylineLayer(polylines:[ + Polyline(points:done, color:AppColors.guindaPrimary, strokeWidth:5, + borderColor:AppColors.guindaDark, borderStrokeWidth:1), + ]), + MarkerLayer(markers:[ + Marker(point:cur, width:52, height:52, + child:Container(decoration:BoxDecoration( + color:gps?AppColors.guindaPrimary:Colors.grey, shape:BoxShape.circle, + border:Border.all(color:Colors.white,width:2.5), + boxShadow:[BoxShadow(color:Colors.black38,blurRadius:6)]), + child:Icon(gps?Icons.local_shipping:Icons.gps_off,color:Colors.white,size:24))), + Marker(point:route.positions.first.latLng, width:28, height:28, + child:Container(decoration:BoxDecoration(color:AppColors.verdeExito,shape:BoxShape.circle, + border:Border.all(color:Colors.white,width:2)), + child:const Icon(Icons.circle,color:Colors.white,size:10))), + Marker(point:route.positions.last.latLng, width:32, height:32, + child:Container(decoration:BoxDecoration(color:AppColors.rojoError,shape:BoxShape.circle, + border:Border.all(color:Colors.white,width:2)), + child:const Icon(Icons.flag,color:Colors.white,size:16))), + ]), + ]))); + } +} + +class AdminMapWidget extends StatefulWidget { + final List routes; + final RouteSimulatorService simulator; + const AdminMapWidget({super.key, required this.routes, required this.simulator}); + @override State createState() => _AdminMapWidgetState(); +} + +class _AdminMapWidgetState extends State { + String? _sel; + static const _colors = [ + Colors.blue, Colors.green, Colors.orange, Colors.purple, Colors.teal, + Colors.red, Colors.indigo, Colors.brown, Colors.cyan, Colors.pink, + Colors.amber, Colors.lime, Colors.deepOrange, Colors.lightBlue, Colors.deepPurple, + ]; + + @override + Widget build(BuildContext context) { + return Column(children:[ + Expanded(child: FlutterMap( + options: const MapOptions(initialCenter:LatLng(20.5211,-100.8196), initialZoom:12), + children:[ + TileLayer(urlTemplate:'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName:'com.example.celaya_limpia'), + PolylineLayer(polylines: widget.routes.asMap().entries.map((e){ + final c = _colors[e.key % _colors.length]; + final isS = _sel==null||_sel==e.value.routeId; + return Polyline(points:_smooth(e.value.polylinePoints), + color:c.withOpacity(isS?0.85:0.2), strokeWidth:isS?4:1.5, + borderColor:Colors.white.withOpacity(isS?0.4:0), borderStrokeWidth:1); + }).toList()), + MarkerLayer(markers: widget.routes.asMap().entries.map((e){ + final r = e.value; + final idx = widget.simulator.getPositionIndex(r.routeId); + final pos = idx < r.positions.length ? r.positions[idx].latLng : r.positions.last.latLng; + final c = _colors[e.key % _colors.length]; + final gps = widget.simulator.isGpsActive(r.routeId); + return Marker(point:pos, width:46, height:46, + child:GestureDetector( + onTap:()=>setState(()=>_sel=_sel==r.routeId?null:r.routeId), + child:Container(decoration:BoxDecoration(color:gps?c:Colors.grey, + shape:BoxShape.circle, border:Border.all(color:Colors.white,width:2), + boxShadow:[BoxShadow(color:Colors.black26,blurRadius:4)]), + child:Center(child:Text(r.truckId.toString().substring(1), + style:const TextStyle(color:Colors.white,fontSize:11,fontWeight:FontWeight.bold)))))); + }).toList()), + ], + )), + if (_sel!=null) ...[ + const Divider(height:1), + Container(padding:const EdgeInsets.symmetric(horizontal:16,vertical:10), + color:AppColors.guindaPrimary, + child:Row(children:[ + const Icon(Icons.local_shipping,color:Colors.white,size:16), + const SizedBox(width:8), + Expanded(child:Text(widget.routes.firstWhere((r)=>r.routeId==_sel).name, + style:const TextStyle(color:Colors.white,fontWeight:FontWeight.bold,fontSize:13))), + Text('Pos ${widget.simulator.getPositionIndex(_sel!)}/7', + style:const TextStyle(color:AppColors.dorado,fontSize:12)), + ])), + ], + ]); + } +} diff --git a/pubspec.lock b/pubspec.lock index fa61e04..0c8fd11 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" async: dependency: transitive description: @@ -17,6 +25,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + camera: + dependency: "direct main" + description: + name: camera + sha256: dfa8fc5a1adaeb95e7a54d86a5bd56f4bb0e035515354c8ac6d262e35cec2ec8 + url: "https://pub.dev" + source: hosted + version: "0.10.6" + camera_android: + dependency: transitive + description: + name: camera_android + sha256: a2001f839b90d97fd40164c29661868403e92de5a98ae03e3f5a12848e606063 + url: "https://pub.dev" + source: hosted + version: "0.10.10+17" + camera_avfoundation: + dependency: transitive + description: + name: camera_avfoundation + sha256: "11b4aee2f5e5e038982e152b4a342c749b414aa27857899d20f4323e94cb5f0b" + url: "https://pub.dev" + source: hosted + version: "0.9.23+2" + camera_platform_interface: + dependency: transitive + description: + name: camera_platform_interface + sha256: "7ac852d77699acee79f0d438b793feee26721841e50973576419ff5c6d95e9b7" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + camera_web: + dependency: transitive + description: + name: camera_web + sha256: "57f49a635c8bf249d07fb95eb693d7e4dda6796dedb3777f9127fb54847beba7" + url: "https://pub.dev" + source: hosted + version: "0.3.5+3" characters: dependency: transitive description: @@ -41,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" cupertino_icons: dependency: "direct main" description: @@ -57,6 +113,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" flutter: dependency: "direct main" description: flutter @@ -66,15 +138,76 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "4.0.0" + flutter_map: + dependency: "direct main" + description: + name: flutter_map + sha256: "87cc8349b8fa5dccda5af50018c7374b6645334a0d680931c1fe11bce88fa5bb" + url: "https://pub.dev" + source: hosted + version: "6.2.1" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: "direct main" + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" + latlong2: + dependency: "direct main" + description: + name: latlong2 + sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe" + url: "https://pub.dev" + source: hosted + version: "0.9.1" leak_tracker: dependency: transitive description: @@ -103,10 +236,26 @@ packages: dependency: transitive description: name: lints - sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "4.0.0" + lists: + dependency: transitive + description: + name: lists + sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + logger: + dependency: transitive + description: + name: logger + sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c" + url: "https://pub.dev" + source: hosted + version: "2.7.0" matcher: dependency: transitive description: @@ -131,14 +280,174 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" - path: + mgrs_dart: dependency: transitive + description: + name: mgrs_dart + sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + path: + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + polylabel: + dependency: transitive + description: + name: polylabel + sha256: "41b9099afb2aa6c1730bdd8a0fab1400d287694ec7615dd8516935fa3144214b" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + proj4dart: + dependency: transitive + description: + name: proj4dart + sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e + url: "https://pub.dev" + source: hosted + version: "2.1.0" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + quiver: + dependency: transitive + description: + name: quiver + sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.dev" + source: hosted + version: "2.4.23" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter @@ -152,6 +461,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" + sqflite: + dependency: "direct main" + description: + name: sqflite + sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a" + url: "https://pub.dev" + source: hosted + version: "2.4.2+1" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40" + url: "https://pub.dev" + source: hosted + version: "2.4.2+3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "1581ffbf7a0e333b380d6a30737d78516b826cb35beb7fb0bf8a3ea0c678b465" + url: "https://pub.dev" + source: hosted + version: "2.5.8" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" stack_trace: dependency: transitive description: @@ -168,6 +517,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: @@ -176,6 +533,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" + url: "https://pub.dev" + source: hosted + version: "3.4.0+1" term_glyph: dependency: transitive description: @@ -192,6 +557,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + tflite_flutter: + dependency: "direct main" + description: + name: tflite_flutter + sha256: "0bba9040d8decda0960d7abf8eabf32243bf092bc7d0084e8e19681866b0bdbe" + url: "https://pub.dev" + source: hosted + version: "0.12.1" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + unicode: + dependency: transitive + description: + name: unicode + sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1" + url: "https://pub.dev" + source: hosted + version: "0.3.1" vector_math: dependency: transitive description: @@ -208,6 +597,38 @@ packages: url: "https://pub.dev" source: hosted version: "15.2.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + wkt_parser: + dependency: transitive + description: + name: wkt_parser + sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" sdks: - dart: ">=3.12.0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + dart: ">=3.11.0 <4.0.0" + flutter: ">=3.38.0" diff --git a/pubspec.yaml b/pubspec.yaml index dba1c1d..c35433e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,89 +1,42 @@ -name: ejemplo -description: "A new Flutter project." -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev - -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# In Windows, build-name is used as the major, minor, and patch parts -# of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+1 +name: celaya_limpia +description: "Sistema Integral de Recolección de Residuos - H. Ayuntamiento de Celaya" +publish_to: 'none' +version: 2.0.0+1 environment: - sdk: ^3.12.0 + sdk: ^3.4.0 -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. dependencies: flutter: sdk: flutter - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 + # Base de datos local + sqflite: ^2.3.3+1 + path: ^1.9.0 + + # Estado y sesión + shared_preferences: ^2.3.2 + provider: ^6.1.2 + + # Mapas (OpenStreetMap - sin API key) + flutter_map: ^6.1.0 + latlong2: ^0.9.1 + # Cámara e IA + camera: ^0.10.6 + tflite_flutter: ^0.12.1 + image: ^4.1.3 + + # Utilidades + intl: ^0.19.0 + http: ^1.2.1 + dev_dependencies: flutter_test: sdk: flutter + flutter_lints: ^4.0.0 - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. - flutter_lints: ^6.0.0 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. uses-material-design: true - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/to/asset-from-package - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package + assets: + - assets/models/ diff --git a/test/widget_test.dart b/test/widget_test.dart index 007bc1f..b259059 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,30 +1,9 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; - -import 'package:ejemplo/main.dart'; +import 'package:celaya_limpia/main.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + testWidgets('App carga correctamente', (WidgetTester tester) async { + await tester.pumpWidget(const CelayaLimpiaApp()); + expect(find.byType(CelayaLimpiaApp), findsOneWidget); }); }