Primera app funcional

This commit is contained in:
2026-05-22 18:27:43 -06:00
parent 43661dc2b0
commit 37e83a8226
30 changed files with 4053 additions and 291 deletions

106
README.md
View File

@@ -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 12 |
| 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

View File

@@ -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")
}
}

View File

@@ -1,6 +1,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<application
android:label="ejemplo"
android:label="Celaya Limpia"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
@@ -12,30 +20,15 @@
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<meta-data android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<meta-data android:name="flutterEmbedding" android:value="2"/>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>

View File

@@ -0,0 +1,5 @@
package com.example.celaya_limpia
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@@ -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<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
// Forzar Java 17 en todos los subproyectos (plugins)
gradle.projectsEvaluated {
subprojects {
tasks.withType<JavaCompile>().configureEach {
sourceCompatibility = "17"
targetCompatibility = "17"
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
}
}

1
assets/models/README.txt Normal file
View File

@@ -0,0 +1 @@
placeholder - add waste_model.tflite here

2
assets/models/labels.txt Normal file
View File

@@ -0,0 +1,2 @@
Orgánico
Inorgánico

Binary file not shown.

73
lib/core/app_colors.dart Normal file
View File

@@ -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<String> 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;
}
}
}

View File

@@ -0,0 +1,51 @@
import '../models/route_model.dart';
final List<ColonyModel> 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<String> get colonyNames => coloniesData.map((c) => c.colonia).toList()..sort();

159
lib/data/routes_data.dart Normal file
View File

@@ -0,0 +1,159 @@
import '../models/route_model.dart';
final List<RouteModel> 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; }
}

185
lib/database/db_helper.dart Normal file
View File

@@ -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<Database> get database async {
_db ??= await _initDb();
return _db!;
}
static Future<Database> _initDb() async {
final path = join(await getDatabasesPath(), 'celaya_v2.db');
return openDatabase(path, version: 1, onCreate: _onCreate);
}
static Future<void> _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<int> insertUser(UserModel u) async =>
(await database).insert('users', u.toMap(), conflictAlgorithm: ConflictAlgorithm.abort);
static Future<UserModel?> 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<UserModel?> 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<List<UserModel>> 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<int> insertDomicilio(DomicilioModel d) async =>
(await database).insert('domicilios', d.toMap());
static Future<DomicilioModel?> 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<void> 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<List<AssignmentModel>> 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<List<AssignmentModel>> getAllAsignaciones() async {
final res = await (await database).query('asignaciones');
return res.map((m) => AssignmentModel.fromMap(m)).toList();
}
// ── ROUTE STATUS ───────────────────────────────────────────────────────
static Future<void> upsertRouteStatus(RouteStatusModel s) async {
final db = await database;
await db.insert('route_status', s.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace);
}
static Future<RouteStatusModel?> 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<List<RouteStatusModel>> getAllRouteStatuses() async {
final res = await (await database).query('route_status');
return res.map((m) => RouteStatusModel.fromMap(m)).toList();
}
// ── ALERTAS ────────────────────────────────────────────────────────────
static Future<int> insertAlerta(AlertaModel a) async =>
(await database).insert('alertas', a.toMap());
static Future<List<AlertaModel>> 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<void> resolverAlerta(int id) async =>
(await database).update('alertas', {'resuelta':1}, where:'id=?', whereArgs:[id]);
// ── REPORTES ───────────────────────────────────────────────────────────
static Future<int> insertReporte(ReporteModel r) async =>
(await database).insert('reportes', r.toMap());
static Future<List<ReporteModel>> 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<List<ReporteModel>> getAllReportes() async {
final res = await (await database).query('reportes', orderBy:'fecha DESC');
return res.map((m) => ReporteModel.fromMap(m)).toList();
}
static Future<void> updateReporteEstado(int id, String estado) async =>
(await database).update('reportes', {'estado':estado}, where:'id=?', whereArgs:[id]);
// ── REPORTES CON INFO DE USUARIO ──────────────────────────────────────
static Future<List<Map<String, dynamic>>> 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<List<AlertaModel>> 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<List<DomicilioModel>> getDomiciliosByRoute(String routeId) async {
final res = await (await database).query('domicilios',
where: 'route_id = ?', whereArgs: [routeId]);
return res.map((m) => DomicilioModel.fromMap(m)).toList();
}
}

View File

@@ -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<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
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(),
},
),
);
}

126
lib/models/models.dart Normal file
View File

@@ -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<String, dynamic> toMap() =>
{'id': id, 'nombre': nombre, 'email': email, 'password': password, 'rol': rol};
factory UserModel.fromMap(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> toMap() => {'id': id, 'conductor_id': conductorId,
'route_id': routeId, 'dia_semana': diaSemana, 'turno': turno};
factory AssignmentModel.fromMap(Map<String, dynamic> 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<String, dynamic> toMap() => {'route_id': routeId, 'status': status,
'mensaje': mensaje, 'updated_at': updatedAt};
factory RouteStatusModel.fromMap(Map<String, dynamic> 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<String, dynamic> toMap() => {'id': id, 'tipo': tipo, 'route_id': routeId,
'mensaje': mensaje, 'fecha': fecha, 'resuelta': resuelta ? 1 : 0};
factory AlertaModel.fromMap(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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);
}

View File

@@ -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<RoutePosition> 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<LatLng> 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});
}

View File

@@ -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<AdminDashboardScreen> createState() => _AdminDashboardScreenState();
}
class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
int _tab = 0;
@override
Widget build(BuildContext context) {
final sim = context.watch<RouteSimulatorService>();
final auth = context.watch<AuthService>();
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<RouteStatusModel> _statuses = [];
List<AlertaModel> _conductorIncidentes = [];
@override void initState() { super.initState(); _load(); }
Future<void> _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<AlertaModel> _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<void> _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<String>(
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<String?> _inputDialog(BuildContext ctx, String hint) async {
final ctrl = TextEditingController();
return showDialog<String>(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<String?> _retrasadaDialog(BuildContext ctx) async {
String turno = 'VESPERTINO';
final ctrl = TextEditingController();
return showDialog<String>(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<String>(dense: true, value: 'MATUTINO',
groupValue: turno, title: const Text('🌄 Matutino'),
onChanged: (v) => setSt(() => turno = v!))),
Expanded(child: RadioListTile<String>(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<String?> _incidenteDialog(BuildContext ctx, String incMensaje) async {
String turnoSeleccionado = 'VESPERTINO';
return showDialog<String>(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<String>(dense: true, value: 'MATUTINO',
groupValue: turnoSeleccionado, title: const Text('🌄 Matutino', style: TextStyle(fontSize: 12)),
onChanged: (v) => setDialogState(() => turnoSeleccionado = v!))),
Expanded(child: RadioListTile<String>(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<Map<String,dynamic>> _reportes = [];
bool _loading = true;
@override void initState() { super.initState(); _load(); }
Future<void> _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<String>(
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<UserModel> _conductores = [];
UserModel? _sel;
List<AssignmentModel> _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<void> _load() async {
final c = await DbHelper.getUsersByRol('CONDUCTOR');
if (mounted) setState(() => _conductores = c);
}
Future<void> _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<String> 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<void> _saveGrupo(List<String> 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<UserModel>(
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<String> 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<String>(
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<String>(
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<AlertaModel> _alertas = [];
bool _soloActivas = false;
@override void initState(){ super.initState(); _load(); }
Future<void> _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),
]))));
}

View File

@@ -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<CameraDescription> _cameras = [];
class AiCameraScreen extends StatefulWidget {
const AiCameraScreen({super.key});
@override State<AiCameraScreen> createState() => _AiCameraScreenState();
}
class _AiCameraScreenState extends State<AiCameraScreen> {
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<void> _init() async {
try {
_cameras = await availableCameras();
} catch (_) {}
await _initCamera();
await _loadModel();
}
Future<void> _initCamera() async {
if (_cameras.isEmpty) return;
_cam = CameraController(_cameras[0], ResolutionPreset.medium, enableAudio: false);
try {
await _cam!.initialize();
if (mounted) setState(() {});
} catch (_) {}
}
Future<void> _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<void> _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<double>.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)),
)),
]),
),
),
]),
);
}
}

View File

@@ -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<String> 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))),
],
])),
])));
}
}

View File

@@ -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<CitizenHomeScreen> createState() => _CitizenHomeScreenState();
}
class _CitizenHomeScreenState extends State<CitizenHomeScreen> {
int _tab = 0;
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthService>();
final sim = context.watch<RouteSimulatorService>();
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<void> _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<Color>(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),
]),
),
),
);
}
}

View File

@@ -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<CitizenReporteScreen> createState() => _CitizenReporteScreenState();
}
class _CitizenReporteScreenState extends State<CitizenReporteScreen> {
String _tipo = 'CAMION_NO_PASO';
final _desc = TextEditingController();
int _calif = 5;
bool _loading = false, _sent = false;
List<ReporteModel> _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<void> _load() async {
final auth = context.read<AuthService>();
if (auth.currentUser == null) return;
final r = await DbHelper.getReportesByUser(auth.currentUser!.id!);
if (mounted) setState(() => _reportes = r);
}
Future<void> _send() async {
final auth = context.read<AuthService>();
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<String>(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<int>(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(); }
}

View File

@@ -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<DriverHomeScreen> createState() => _DriverHomeScreenState();
}
class _DriverHomeScreenState extends State<DriverHomeScreen> {
int _tab = 0;
List<AssignmentModel> _assignments = [];
String? _todayRouteId;
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final auth = context.read<AuthService>();
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<RouteSimulatorService>().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<AuthService>();
final sim = context.watch<RouteSimulatorService>();
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<ReporteModel> _ciudadanoReportes = [];
@override void initState() { super.initState(); _loadReportes(); }
Future<void> _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<Color>(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<AssignmentModel> 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<AlertaModel> _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<void> _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<void> _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<String>(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),
]))));
}
}

View File

@@ -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<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _emailCtrl = TextEditingController();
final _passCtrl = TextEditingController();
bool _loading = false, _obscure = true;
Future<void> _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<AuthService>().login(_emailCtrl.text, _passCtrl.text);
if (!mounted) return;
setState(() => _loading = false);
if (err != null) { _snack(err, isError: true); return; }
final rol = context.read<AuthService>().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(); }
}

View File

@@ -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<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends State<RegisterScreen> {
final _nombre = TextEditingController();
final _email = TextEditingController();
final _pass = TextEditingController();
final _calle = TextEditingController();
ColonyModel? _colony;
bool _loading = false, _obscure = true;
Future<void> _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<AuthService>().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<String>(
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(); }
}

View File

@@ -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<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen> {
@override
void initState() { super.initState(); _go(); }
Future<void> _go() async {
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return;
final auth = context.read<AuthService>();
context.read<RouteSimulatorService>().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<Color>(AppColors.dorado)),
])),
);
}

View File

@@ -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<void> _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<String?> 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<String?> 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<void> logout() async {
_user = null; _domicilio = null;
final p = await SharedPreferences.getInstance();
await p.remove('user_id');
notifyListeners();
}
}

View File

@@ -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<String, SimulatorState> _states = {};
Timer? _globalTimer;
Timer? _gpsMonitorTimer;
AppNotification? _lastNotification; // Admin ve todas
final List<AppNotification> _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<AppNotification> get history => List.unmodifiable(_history);
// Historial filtrado por ruta
List<AppNotification> 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<void> _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<void> 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();
}
}

View File

@@ -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<LatLng> _smooth(List<LatLng> pts) {
if (pts.length < 2) return pts;
final res = <LatLng>[];
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())
: <LatLng>[];
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<RouteModel> routes;
final RouteSimulatorService simulator;
const AdminMapWidget({super.key, required this.routes, required this.simulator});
@override State<AdminMapWidget> createState() => _AdminMapWidgetState();
}
class _AdminMapWidgetState extends State<AdminMapWidget> {
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)),
])),
],
]);
}
}

View File

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

View File

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

View File

@@ -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);
});
}