Primera app funcional
This commit is contained in:
106
README.md
106
README.md
@@ -1,66 +1,90 @@
|
|||||||
# AppRecoleccion
|
# 🗑️ Celaya Limpia — Sistema Integral de Recolección de Residuos
|
||||||
|
|
||||||
Es una aplicación diseñada para el monitoreo y control de los camiones de basura permitiéndole al usuario saber cuando el camión de basura se encuentre cerca de su domicilio
|
## H. Ayuntamiento de Celaya, Guanajuato
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# Problemática de Aplicación Móvil
|
## 👥 Roles del Sistema
|
||||||
|
|
||||||
Sistema de Notificación Inteligente y Privada de Recolección de Residuos
|
### 🏠 CIUDADANO
|
||||||
|
- ETA del camión recolector en tiempo real
|
||||||
|
- **Mapa visible SOLO cuando el camión está a <15 min**
|
||||||
|
- Guía de separación de residuos (sin internet)
|
||||||
|
- Clasificador de residuos con IA (cámara)
|
||||||
|
- Reporte de incidencias
|
||||||
|
|
||||||
En la actualidad, la ciudadanía no cuenta con certeza sobre el momento exacto en que el camión recolector de basura pasará por su domicilio. Esto provoca un efecto dominó negativo: la basura se saca a la calle demasiado temprano, demasiado tarde o cuando la unidad ya pasó.
|
### 🚛 CONDUCTOR
|
||||||
|
- Vista de su ruta asignada por día
|
||||||
|
- Mapa de su ruta específica
|
||||||
|
- Alertas de GPS desactivado
|
||||||
|
- Horario semanal asignado por el admin
|
||||||
|
|
||||||
La falta de información oportuna genera problemas de salud pública, fauna nociva, molestias vecinales y una disposición ineficiente de los residuos. Sin embargo, la solución no es simplemente ponerle un GPS al camión y mostrarlo en un mapa público. Mostrar el recorrido completo o las rutas de otros sectores incrementa el riesgo de uso inadecuado del servicio (como personas intentando alcanzar el camión en movimiento) y expone información operativa sensible de la flotilla municipal.
|
### ⚙️ ADMINISTRADOR
|
||||||
|
- Mapa de TODAS las rutas simultáneamente
|
||||||
|
- Control de estado de rutas (cancelar, marcar falla, retraso)
|
||||||
|
- Gestión de reportes ciudadanos
|
||||||
|
- Asignación de rutas a conductores por día/turno
|
||||||
|
- Panel de alertas (GPS perdido, camión detenido +30min)
|
||||||
|
|
||||||
Adicionalmente, existe una fuerte necesidad de educación ciudadana: la gente quiere separar su basura, pero muchas veces no sabe cómo hacerlo correctamente.
|
---
|
||||||
|
|
||||||
El Reto Desarrollar el prototipo de una aplicación móvil orientada a la ciudadanía que indique una hora aproximada de llegada del camión recolector al domicilio registrado, que notifique sobre cambios operativos y que eduque sobre la separación de residuos.
|
## 🔑 Cuentas Demo
|
||||||
|
|
||||||
Todo esto bajo un principio innegociable de Privacidad por Diseño: el usuario debe obtener la información que necesita sin acceder al mapa completo de la ruta ni rastrear el vehículo en tiempo real.
|
| Rol | Email | Contraseña |
|
||||||
|
|-----|-------|-----------|
|
||||||
|
| Administrador | admin@celaya.gob.mx | admin123 |
|
||||||
|
| Conductor | conductor@celaya.gob.mx | conductor123 |
|
||||||
|
| Ciudadano | Crear desde la app | - |
|
||||||
|
|
||||||
Requisitos Funcionales (MVP)
|
---
|
||||||
|
|
||||||
Gestión de Identidad y Domicilios: Registro de usuario seguro (email o teléfono) y capacidad de dar de alta y validar uno o más domicilios (ejemplo: casa y trabajo).
|
## 🚀 Cómo ejecutar
|
||||||
|
|
||||||
Ventana de Llegada (ETA): La interfaz debe traducir los datos operativos en un mensaje claro y orientado a la acción: "El camión llegará a tu zona entre las 7:20 y 7:35 p.m." o "Llega en aproximadamente 15 minutos".
|
```bash
|
||||||
|
flutter pub get
|
||||||
|
flutter run
|
||||||
|
```
|
||||||
|
|
||||||
Alertas y Notificaciones Push: Avisos configurables cuando la ruta esté por iniciar, cuando el camión se aproxime o si hay retrasos por tráfico o fallas mecánicas.
|
---
|
||||||
|
|
||||||
Visión de "Túnel" (Ruta Exclusiva): El ciudadano solo puede ver la información de la ruta asignada a su dirección validada. Cero visibilidad de colonias vecinas.
|
## 🤖 IA de Clasificación de Residuos
|
||||||
|
|
||||||
Guía de Separación Integrada: Una sección visual, rápida y que funcione sin conexión con categorías claras (orgánicos, reciclables, sanitarios, especiales) para educar al ciudadano.
|
1. Convierte tu modelo: `waste_clasification.h5` → `waste_model.tflite`
|
||||||
|
2. Coloca el archivo en: `assets/models/waste_model.tflite`
|
||||||
|
3. El modelo clasifica: **Orgánico (0) / Inorgánico (1)**
|
||||||
|
|
||||||
Buzón de Retroalimentación: Módulo básico para reportar incidencias (ejemplo: "El camión no pasó") o calificar el servicio.
|
Script de conversión:
|
||||||
|
```python
|
||||||
|
import tensorflow as tf
|
||||||
|
model = tf.keras.models.load_model('waste_clasification.h5')
|
||||||
|
converter = tf.lite.TFLiteConverter.from_keras_model(model)
|
||||||
|
tflite_model = converter.convert()
|
||||||
|
with open('assets/models/waste_model.tflite', 'wb') as f:
|
||||||
|
f.write(tflite_model)
|
||||||
|
```
|
||||||
|
|
||||||
Restricciones de Diseño (Reglas del Juego)
|
---
|
||||||
|
|
||||||
PROHIBIDO el Seguidor de Ruta Activo: La app no debe mostrar un mapa con el camión moviéndose en tiempo real.
|
## 🗺️ Mapas
|
||||||
|
|
||||||
PROHIBIDO el Snooping: No se debe poder explorar las rutas de otros domicilios o usuarios.
|
Usa **OpenStreetMap** (gratuito, sin API Key)
|
||||||
|
|
||||||
Mensajería Preventiva: La interfaz debe desalentar explícitamente a los usuarios de sacar la basura fuera del horario establecido o de perseguir a la unidad.
|
---
|
||||||
|
|
||||||
Requisitos Técnicos y Arquitectura
|
## 🔔 Notificaciones Simuladas
|
||||||
|
|
||||||
Se requiere demostrar una arquitectura de software sólida, escalable y pensada para un despliegue real.
|
| Evento | Cuándo |
|
||||||
|
|--------|--------|
|
||||||
|
| 🚛 Ruta Iniciada | posición 1→2 |
|
||||||
|
| ⚠️ Camión Cercano | posición 4 (~15 min) |
|
||||||
|
| 🏁 Servicio Finalizado | posición 8 |
|
||||||
|
| 📡 GPS Perdido | Admin lo activa manualmente |
|
||||||
|
| ⚠️ Camión Detenido | Sin movimiento 30+ min |
|
||||||
|
|
||||||
Arquitectura de Software (Backend): Se espera la implementación de una API RESTful limpia (preferentemente en frameworks modernos como Go, Python/FastAPI, Node.js o C#/.NET) que separe la lógica de negocio de la capa de datos.
|
---
|
||||||
|
|
||||||
Simulación de Eventos (Mocking): Dado que no tendrán acceso a la telemetría real de los camiones, deberán simular el avance de la ruta en el backend mediante un trabajo programado (Cron Job), WebSockets o un script de simulación que actualice el estado y dispare las notificaciones push en tiempo real.
|
## 📋 Rutas disponibles
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
|
- 15 rutas con GPS real de Celaya
|
||||||
|
- Turnos: Matutino, Vespertino, Nocturno
|
||||||
|
- 40+ colonias mapeadas
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
|
||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.example.ejemplo"
|
namespace = "com.example.celaya_limpia"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = "28.2.13676358"
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
@@ -15,10 +14,7 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
applicationId = "com.example.celaya_limpia"
|
||||||
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.
|
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
@@ -27,8 +23,6 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
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")
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<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
|
<application
|
||||||
android:label="ejemplo"
|
android:label="Celaya Limpia"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
<activity
|
<activity
|
||||||
@@ -12,30 +20,15 @@
|
|||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
<meta-data android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
the Android process has started. This theme is visible to the user
|
android:resource="@style/NormalTheme"/>
|
||||||
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"
|
|
||||||
/>
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<!-- Don't delete the meta-data below.
|
<meta-data android:name="flutterEmbedding" android:value="2"/>
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
|
||||||
<meta-data
|
|
||||||
android:name="flutterEmbedding"
|
|
||||||
android:value="2" />
|
|
||||||
</application>
|
</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>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.example.celaya_limpia
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity()
|
||||||
@@ -5,15 +5,11 @@ allprojects {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val newBuildDir: Directory =
|
val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get()
|
||||||
rootProject.layout.buildDirectory
|
|
||||||
.dir("../../build")
|
|
||||||
.get()
|
|
||||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||||
|
|
||||||
subprojects {
|
subprojects {
|
||||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
layout.buildDirectory.value(newSubprojectBuildDir)
|
||||||
}
|
}
|
||||||
subprojects {
|
subprojects {
|
||||||
project.evaluationDependsOn(":app")
|
project.evaluationDependsOn(":app")
|
||||||
@@ -22,3 +18,18 @@ subprojects {
|
|||||||
tasks.register<Delete>("clean") {
|
tasks.register<Delete>("clean") {
|
||||||
delete(rootProject.layout.buildDirectory)
|
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
1
assets/models/README.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
placeholder - add waste_model.tflite here
|
||||||
2
assets/models/labels.txt
Normal file
2
assets/models/labels.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
Orgánico
|
||||||
|
Inorgánico
|
||||||
BIN
assets/models/waste_model.tflite
Normal file
BIN
assets/models/waste_model.tflite
Normal file
Binary file not shown.
73
lib/core/app_colors.dart
Normal file
73
lib/core/app_colors.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
lib/data/colonies_data.dart
Normal file
51
lib/data/colonies_data.dart
Normal 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
159
lib/data/routes_data.dart
Normal 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
185
lib/database/db_helper.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
151
lib/main.dart
151
lib/main.dart
@@ -1,121 +1,56 @@
|
|||||||
import 'package:flutter/material.dart';
|
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() {
|
void main() async {
|
||||||
runApp(const MyApp());
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
runApp(const CelayaLimpiaApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
class MyApp extends StatelessWidget {
|
class CelayaLimpiaApp extends StatelessWidget {
|
||||||
const MyApp({super.key});
|
const CelayaLimpiaApp({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++;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// This method is rerun every time setState is called, for instance as done
|
return MultiProvider(
|
||||||
// by the _incrementCounter method above.
|
providers: [
|
||||||
//
|
ChangeNotifierProvider(create: (_) => AuthService()),
|
||||||
// The Flutter framework has been optimized to make rerunning build methods
|
ChangeNotifierProvider(create: (_) => RouteSimulatorService()),
|
||||||
// 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,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
|
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(
|
initialRoute: '/splash',
|
||||||
onPressed: _incrementCounter,
|
routes: {
|
||||||
tooltip: 'Increment',
|
'/splash': (_) => const SplashScreen(),
|
||||||
child: const Icon(Icons.add),
|
'/login': (_) => const LoginScreen(),
|
||||||
|
'/register': (_) => const RegisterScreen(),
|
||||||
|
'/home': (_) => const CitizenHomeScreen(),
|
||||||
|
'/driver': (_) => const DriverHomeScreen(),
|
||||||
|
'/admin': (_) => const AdminDashboardScreen(),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
126
lib/models/models.dart
Normal file
126
lib/models/models.dart
Normal 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);
|
||||||
|
}
|
||||||
39
lib/models/route_model.dart
Normal file
39
lib/models/route_model.dart
Normal 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});
|
||||||
|
}
|
||||||
786
lib/screens/admin/admin_dashboard_screen.dart
Normal file
786
lib/screens/admin/admin_dashboard_screen.dart
Normal 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),
|
||||||
|
]))));
|
||||||
|
}
|
||||||
175
lib/screens/citizen/ai_camera_screen.dart
Normal file
175
lib/screens/citizen/ai_camera_screen.dart
Normal 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)),
|
||||||
|
)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
149
lib/screens/citizen/citizen_guia_screen.dart
Normal file
149
lib/screens/citizen/citizen_guia_screen.dart
Normal 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))),
|
||||||
|
],
|
||||||
|
])),
|
||||||
|
])));
|
||||||
|
}
|
||||||
|
}
|
||||||
448
lib/screens/citizen/citizen_home_screen.dart
Normal file
448
lib/screens/citizen/citizen_home_screen.dart
Normal 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),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
117
lib/screens/citizen/citizen_reporte_screen.dart
Normal file
117
lib/screens/citizen/citizen_reporte_screen.dart
Normal 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(); }
|
||||||
|
}
|
||||||
456
lib/screens/driver/driver_home_screen.dart
Normal file
456
lib/screens/driver/driver_home_screen.dart
Normal 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),
|
||||||
|
]))));
|
||||||
|
}
|
||||||
|
}
|
||||||
106
lib/screens/login_screen.dart
Normal file
106
lib/screens/login_screen.dart
Normal 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(); }
|
||||||
|
}
|
||||||
101
lib/screens/register_screen.dart
Normal file
101
lib/screens/register_screen.dart
Normal 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(); }
|
||||||
|
}
|
||||||
53
lib/screens/splash_screen.dart
Normal file
53
lib/screens/splash_screen.dart
Normal 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)),
|
||||||
|
])),
|
||||||
|
);
|
||||||
|
}
|
||||||
70
lib/services/auth_service.dart
Normal file
70
lib/services/auth_service.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
204
lib/services/route_simulator_service.dart
Normal file
204
lib/services/route_simulator_service.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
146
lib/widgets/route_map_widget.dart
Normal file
146
lib/widgets/route_map_widget.dart
Normal 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)),
|
||||||
|
])),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
435
pubspec.lock
435
pubspec.lock
@@ -1,6 +1,14 @@
|
|||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
packages:
|
||||||
|
archive:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: archive
|
||||||
|
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.9"
|
||||||
async:
|
async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -17,6 +25,46 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
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:
|
characters:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -41,6 +89,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
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:
|
cupertino_icons:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -57,6 +113,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.3"
|
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:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -66,15 +138,76 @@ packages:
|
|||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: flutter_lints
|
name: flutter_lints
|
||||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
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:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -103,10 +236,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: lints
|
name: lints
|
||||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
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:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -131,14 +280,174 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.18.0"
|
||||||
path:
|
mgrs_dart:
|
||||||
dependency: transitive
|
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:
|
description:
|
||||||
name: path
|
name: path
|
||||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
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:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -152,6 +461,46 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.2"
|
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:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -168,6 +517,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
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:
|
string_scanner:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -176,6 +533,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
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:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -192,6 +557,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
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:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -208,6 +597,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.2.0"
|
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:
|
sdks:
|
||||||
dart: ">=3.12.0 <4.0.0"
|
dart: ">=3.11.0 <4.0.0"
|
||||||
flutter: ">=3.18.0-18.0.pre.54"
|
flutter: ">=3.38.0"
|
||||||
|
|||||||
103
pubspec.yaml
103
pubspec.yaml
@@ -1,89 +1,42 @@
|
|||||||
name: ejemplo
|
name: celaya_limpia
|
||||||
description: "A new Flutter project."
|
description: "Sistema Integral de Recolección de Residuos - H. Ayuntamiento de Celaya"
|
||||||
# The following line prevents the package from being accidentally published to
|
publish_to: 'none'
|
||||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
version: 2.0.0+1
|
||||||
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
|
|
||||||
|
|
||||||
environment:
|
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:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: 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
|
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:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
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:
|
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
|
uses-material-design: true
|
||||||
|
assets:
|
||||||
# To add assets to your application, add an assets section, like this:
|
- assets/models/
|
||||||
# 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
|
|
||||||
|
|||||||
@@ -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:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:celaya_limpia/main.dart';
|
||||||
import 'package:ejemplo/main.dart';
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
testWidgets('App carga correctamente', (WidgetTester tester) async {
|
||||||
// Build our app and trigger a frame.
|
await tester.pumpWidget(const CelayaLimpiaApp());
|
||||||
await tester.pumpWidget(const MyApp());
|
expect(find.byType(CelayaLimpiaApp), findsOneWidget);
|
||||||
|
|
||||||
// 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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user