Actualizacion del programa

This commit is contained in:
2026-05-23 01:40:39 -06:00
parent 458af32fcf
commit c6a1a67469
132 changed files with 11009 additions and 168 deletions

View File

@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
class AppColors {
static const Color guindaPrimary = Color(0xFF6D1E3A);
static const Color guindaDark = Color(0xFF4A1228);
static const Color guindaLight = Color(0xFF9B3D5C);
static const Color dorado = Color(0xFFC9A84C);
static const Color blanco = Color(0xFFFFFFFF);
static const Color grisFondo = Color(0xFFF5F5F5);
static const Color grisTexto = Color(0xFF757575);
static const Color negroTexto = Color(0xFF212121);
static const Color verdeExito = Color(0xFF2E7D32);
static const Color rojoError = Color(0xFFC62828);
static const Color naranjaAlerta = Color(0xFFE65100);
static const Color azulInfo = Color(0xFF1565C0);
static const Color moradoConductor= Color(0xFF4A148C);
static const Color verdeAdmin = Color(0xFF1B5E20);
}
class AppRoles {
static const String ciudadano = 'CIUDADANO';
static const String conductor = 'CONDUCTOR';
static const String administrador = 'ADMINISTRADOR';
}
class AppTurnos {
static const String matutino = 'MATUTINO';
static const String vespertino= 'VESPERTINO';
static const String nocturno = 'NOCTURNO';
}
class AppDias {
static const List<String> todos = [
'LUNES','MARTES','MIERCOLES','JUEVES','VIERNES','SABADO','DOMINGO'
];
static String label(String dia) {
const m = {
'LUNES':'Lunes','MARTES':'Martes','MIERCOLES':'Miércoles',
'JUEVES':'Jueves','VIERNES':'Viernes','SABADO':'Sábado','DOMINGO':'Domingo',
};
return m[dia] ?? dia;
}
}
class RouteStatus {
static const String enRuta = 'EN_RUTA';
static const String cancelada = 'CANCELADA';
static const String retrasada = 'RETRASADA';
static const String fallaMecanica= 'FALLA_MECANICA';
static const String finalizada = 'FINALIZADA';
static Color color(String status) {
switch (status) {
case enRuta: return AppColors.verdeExito;
case cancelada: return AppColors.rojoError;
case retrasada: return AppColors.naranjaAlerta;
case fallaMecanica: return Colors.red.shade900;
case finalizada: return AppColors.grisTexto;
default: return AppColors.grisTexto;
}
}
static String label(String status) {
switch (status) {
case enRuta: return '🚛 En Ruta';
case cancelada: return '❌ Cancelada';
case retrasada: return '⏱️ Retrasada';
case fallaMecanica: return '🔧 Falla Mecánica';
case finalizada: return '✅ Finalizada';
default: return status;
}
}
}

View File

@@ -0,0 +1,243 @@
// 240 colonias oficiales de Celaya, Guanajuato
const List<String> celayaColonias = [
'10 de Abril',
'10 de mayo',
'15 de Mayo',
'3 Guerras',
'Alameda',
'Álamos',
'Álamos Oriente',
'Alfredo Vázquez Bonfil',
'Américas del Bajío',
'Arboledas de Camargo',
'Arboledas del Campestre',
'Arcada Alameda',
'Baalam Residencial',
'Benito Juárez',
'Bosques de la Alameda',
'Bosques del Sol',
'Brisas del Carmen',
'Bugambilias',
'Calesa',
'Camargo',
'Campestre Celaya',
'Canal de Camargo',
'Canal de Labradores',
'Capitales de Europa',
'Celaya Centro',
'Ciudadela',
'Ciudad Industrial',
'Claustros de Arboledas',
'Conjunto Habitacional Girasoles',
'Cuauhtémoc',
'Del Bosque',
'Del Parque',
'Del Valle',
'Don Gu',
'Dos Plazas',
'Ejidal',
'El Atrio',
'El Campanario',
'El Campanario Residencial',
'El Cantar',
'El Dorado',
'El Haba',
'El Junco Residencial',
'El Olivar',
'El Panamericano',
'El Paraíso de los Ángeles',
'El Vergel',
'Emeteria Valencia',
'Emiliano Zapata',
'Emiliano Zapata Sur',
'Enrique Colunga',
'Esmeralda',
'Exelaris',
'Felipe Ángeles',
'Floresta del Sur',
'FOVISSSTE',
'Galaxias del Parque',
'Geo Villas Los Sauces',
'Gobernadores',
'Granada',
'Gran Clase',
'Guadalupe',
'Guanajuato',
'Hacienda del Bosque',
'Hacienda del Sol',
'Hidalgo',
'Imperial',
'Independencia',
'Industriales',
'Jacarandas',
'Jardines de Celaya 1a Secc',
'Jardines de Celaya 2a Secc',
'Jardines de Celaya 3a Secc',
'Jardines del Centro',
'Jardines del Sur',
'José Suárez Irigoyen',
'Juan Pablo II',
'Karina',
'La Campiña',
'La Capilla',
'La Cruz',
'La Escondida',
'La Favorita',
'La Fundación',
'La Herradura',
'La Joya',
'La Misión',
'La Purísima',
'Las Alamedas',
'Las Américas',
'Las Arboledas',
'Las Arenas',
'Las Aves',
'Las Brisas',
'Las Carretas',
'Las Casas',
'Las Delicias',
'Las Flores',
'Las Fuentes',
'Las Insurgentes',
'La Soledad',
'Latinoamericana',
'La Trinidad',
'Lázaro Cárdenas',
'Lindavista',
'López Portillo',
'Los Ángeles',
'Los Frailes',
'Los Impresionistas',
'Los Lagos',
'Los Laureles',
'Los Naranjos',
'Los Olivos Residencial',
'Los Pinos',
'Los Pirules',
'Los Pirules Don Gu',
'Los Portones',
'Los Santos',
'Los Sauces',
'Los Tules',
'Los Veintes',
'Magno Residencial',
'Mediterráneo',
'México',
'Miguel Alemán',
'Misión de La Esperanza',
'Misión Santa Fe',
'Moctezuma',
'Monte Blanco',
'Nat Tha Hi',
'Nueva Santa María',
'Nueva Terraza',
'Nuevo Celaya',
'Nuevo Tecnológico',
'Obrero Mundial',
'Oro',
'Palas Atenea',
'Palma Real',
'Parque Central',
'Parque Verde',
'Pedregal del Junco',
'Porta Maggiore',
'Portones de la Hacienda',
'Praderas del Bosque',
'Praderas de Santa Julia',
'Praderas de Santa Lucía',
'Prados el Naranjal',
'Privada Ciruelo',
'Privada del Pedregal',
'Privada del Real',
'Privada el Sauz',
'Progreso Solidaridad',
'Providencia',
'Puerta Grande',
'Puertas del Sol',
'Puertas de Santa María',
'Puesta del Sol',
'Punta Norte',
'Quinta Santa María',
'Raquet Club Cross',
'Real de Celaya',
'Real de San Antonio',
'Recursos Hidráulicos',
'Reforma',
'Reforma',
'Residencial Las Margaritas',
'Residencial Las Praderas',
'Residencial Paraíso',
'Residencial San Pablo',
'Residencial Santiago',
'Residencial Tecnológico',
'Residencial Xochipilli',
'Resurrección',
'Revolución',
'Rinconada del Bosque',
'Rinconada Laureles',
'Rinconada Los Álamos',
'Rinconada San Jorge',
'Rincón de Cantarranas',
'Riveras del Campestre',
'Rosalinda',
'San Andrés',
'San Antonio',
'San Antonio',
'San Francisco',
'San Gabriel',
'San José de Torres',
'San Juan',
'San Juan de Dios',
'San Juanico',
'San Juanico 1a Secc',
'San Juanico 2a Secc',
'San Martín de Camargo',
'San Miguel',
'San Rafael',
'San Román',
'Santa Anita',
'Santa Bárbara',
'Santa Cecilia',
'Santa Fe de los Naranjos',
'Santa Isabel',
'Santa María',
'Santa María',
'Santa Rita',
'Santa Teresa',
'Santiaguito',
'Suiza',
'Tahi',
'Tierras Negras',
'Tierra y Libertad',
'Tres Lunas',
'Valle de La Primavera',
'Valle de los Naranjos III Sección',
'Valle de los Naranjos II Sección',
'Valle del Real',
'Valle Hermoso',
'Valle Naranjos',
'Ventanales de Santa María',
'Villa Arbolada',
'Villa de Celaya',
'Villa de los Álamos',
'Villa de los Reyes',
'Villa Jardín',
'Villas de Benavente',
'Villas de Benavente II',
'Villas de La Esperanza',
'Villas de La Hacienda',
'Villas del Bajío',
'Villas del Palmar',
'Villas del Paraíso',
'Villas del Rocío',
'Villas del Romeral',
'Villas del Tenis',
'Villas Reales',
'Villas Vicenza',
'Viñas de La Herradura',
'Virgen del Refugio',
'Zempoala',
'Zona de Oro',
'Zona de Oro del Bajío',
];

View File

@@ -0,0 +1,51 @@
import '../models/route_model.dart';
final List<ColonyModel> coloniesData = [
ColonyModel(colonia:'Zona Centro',routeId:'RUTA-01',horarioEstimado:'Matutino (06:30-07:15)'),
ColonyModel(colonia:'Las Arboledas',routeId:'RUTA-01',horarioEstimado:'Matutino (07:00-07:30)'),
ColonyModel(colonia:'Centro Histórico',routeId:'RUTA-01',horarioEstimado:'Matutino (06:20-07:00)'),
ColonyModel(colonia:'Barrio de Santiago',routeId:'RUTA-01',horarioEstimado:'Matutino (06:30-07:10)'),
ColonyModel(colonia:'Col. Obrera',routeId:'RUTA-01',horarioEstimado:'Matutino (06:50-07:25)'),
ColonyModel(colonia:'Av. Tecnológico',routeId:'RUTA-02',horarioEstimado:'Matutino (06:20-07:00)'),
ColonyModel(colonia:'Col. Magisterial',routeId:'RUTA-02',horarioEstimado:'Matutino (06:40-07:15)'),
ColonyModel(colonia:'Fracc. Las Américas',routeId:'RUTA-02',horarioEstimado:'Matutino (06:55-07:30)'),
ColonyModel(colonia:'Col. Constitución',routeId:'RUTA-02',horarioEstimado:'Matutino (06:30-07:05)'),
ColonyModel(colonia:'San Juanico',routeId:'RUTA-03',horarioEstimado:'Vespertino (14:45-15:15)'),
ColonyModel(colonia:'Col. Los Álamos',routeId:'RUTA-03',horarioEstimado:'Vespertino (14:30-15:00)'),
ColonyModel(colonia:'Fracc. El Dorado',routeId:'RUTA-03',horarioEstimado:'Vespertino (15:00-15:30)'),
ColonyModel(colonia:'Los Olivos',routeId:'RUTA-04',horarioEstimado:'Matutino (07:00-07:40)'),
ColonyModel(colonia:'Col. Revolución',routeId:'RUTA-04',horarioEstimado:'Matutino (06:35-07:10)'),
ColonyModel(colonia:'Col. Ladrillera',routeId:'RUTA-04',horarioEstimado:'Matutino (06:50-07:25)'),
ColonyModel(colonia:'Rancho Seco',routeId:'RUTA-05',horarioEstimado:'Vespertino (15:00-15:35)'),
ColonyModel(colonia:'Col. El Potrero',routeId:'RUTA-05',horarioEstimado:'Vespertino (14:45-15:20)'),
ColonyModel(colonia:'Col. Los Sauces',routeId:'RUTA-05',horarioEstimado:'Vespertino (15:15-15:50)'),
ColonyModel(colonia:'Rumbos de Roque',routeId:'RUTA-06',horarioEstimado:'Matutino (06:30-07:10)'),
ColonyModel(colonia:'Col. Vista Hermosa',routeId:'RUTA-06',horarioEstimado:'Matutino (06:45-07:20)'),
ColonyModel(colonia:'Ciudad Industrial',routeId:'RUTA-07',horarioEstimado:'Matutino (06:30-07:10)'),
ColonyModel(colonia:'Parque Industrial',routeId:'RUTA-07',horarioEstimado:'Matutino (06:50-07:25)'),
ColonyModel(colonia:'Universidad Latina',routeId:'RUTA-08',horarioEstimado:'Nocturno (22:30-23:00)'),
ColonyModel(colonia:'Col. Del Moral',routeId:'RUTA-08',horarioEstimado:'Nocturno (22:00-22:30)'),
ColonyModel(colonia:'Hospital General',routeId:'RUTA-09',horarioEstimado:'Matutino (06:20-07:00)'),
ColonyModel(colonia:'Col. Peñuelas',routeId:'RUTA-09',horarioEstimado:'Matutino (06:50-07:20)'),
ColonyModel(colonia:'UG Sur',routeId:'RUTA-10',horarioEstimado:'Nocturno (21:30-22:00)'),
ColonyModel(colonia:'Eje Juan Pablo II',routeId:'RUTA-10',horarioEstimado:'Nocturno (21:00-21:30)'),
ColonyModel(colonia:'Torres Landa',routeId:'RUTA-11',horarioEstimado:'Matutino (06:45-07:15)'),
ColonyModel(colonia:'Zona de Oro',routeId:'RUTA-11',horarioEstimado:'Matutino (06:30-07:00)'),
ColonyModel(colonia:'Las Insurgentes',routeId:'RUTA-12',horarioEstimado:'Matutino (06:35-07:10)'),
ColonyModel(colonia:'Col. Independencia',routeId:'RUTA-12',horarioEstimado:'Matutino (06:50-07:20)'),
ColonyModel(colonia:'Trojes',routeId:'RUTA-13',horarioEstimado:'Matutino (06:40-07:10)'),
ColonyModel(colonia:'Irrigación',routeId:'RUTA-13',horarioEstimado:'Matutino (06:55-07:25)'),
ColonyModel(colonia:'Col. Benito Juárez',routeId:'RUTA-13',horarioEstimado:'Matutino (06:30-07:00)'),
ColonyModel(colonia:'La Toscana',routeId:'RUTA-14',horarioEstimado:'Vespertino (15:00-15:35)'),
ColonyModel(colonia:'Fracc. La Laborcita',routeId:'RUTA-14',horarioEstimado:'Vespertino (14:45-15:20)'),
ColonyModel(colonia:'San José de Celaya',routeId:'RUTA-15',horarioEstimado:'Nocturno (22:45-23:20)'),
ColonyModel(colonia:'Col. Camino Real',routeId:'RUTA-15',horarioEstimado:'Nocturno (22:30-23:00)'),
ColonyModel(colonia:'Col. Jardín',routeId:'RUTA-15',horarioEstimado:'Nocturno (23:00-23:30)'),
];
ColonyModel? getColonyByName(String name) {
try { return coloniesData.firstWhere((c) => c.colonia.toLowerCase() == name.toLowerCase()); }
catch (_) { return null; }
}
List<String> get colonyNames => coloniesData.map((c) => c.colonia).toList()..sort();

View File

@@ -0,0 +1,159 @@
import '../models/route_model.dart';
final List<RouteModel> routesData = [
RouteModel(routeId:'RUTA-01',name:'Zona Centro - Las Arboledas',truckId:101,status:'EN_RUTA',turno:'MATUTINO',positions:[
RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'06:00'),
RoutePosition(positionId:2,lat:20.5185,lng:-100.8450,speed:45,timestamp:'06:12'),
RoutePosition(positionId:3,lat:20.5215,lng:-100.8142,speed:22,timestamp:'06:25'),
RoutePosition(positionId:4,lat:20.5212,lng:-100.8175,speed:15,timestamp:'06:38'),
RoutePosition(positionId:5,lat:20.5210,lng:-100.8210,speed:0,timestamp:'06:50'),
RoutePosition(positionId:6,lat:20.5235,lng:-100.8212,speed:18,timestamp:'07:05'),
RoutePosition(positionId:7,lat:20.5260,lng:-100.8215,speed:20,timestamp:'07:18'),
RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:40,timestamp:'07:40'),
]),
RouteModel(routeId:'RUTA-02',name:'Sector Norte - Av. Tecnológico',truckId:102,status:'EN_RUTA',turno:'MATUTINO',positions:[
RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'06:05'),
RoutePosition(positionId:2,lat:20.5280,lng:-100.8135,speed:38,timestamp:'06:18'),
RoutePosition(positionId:3,lat:20.5410,lng:-100.8130,speed:25,timestamp:'06:30'),
RoutePosition(positionId:4,lat:20.5445,lng:-100.8132,speed:12,timestamp:'06:45'),
RoutePosition(positionId:5,lat:20.5480,lng:-100.8135,speed:0,timestamp:'06:58'),
RoutePosition(positionId:6,lat:20.5515,lng:-100.8138,speed:15,timestamp:'07:10'),
RoutePosition(positionId:7,lat:20.5540,lng:-100.8110,speed:22,timestamp:'07:25'),
RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:45,timestamp:'07:50'),
]),
RouteModel(routeId:'RUTA-03',name:'Sector Poniente - San Juanico',truckId:103,status:'EN_RUTA',turno:'VESPERTINO',positions:[
RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'14:00'),
RoutePosition(positionId:2,lat:20.5250,lng:-100.8510,speed:42,timestamp:'14:15'),
RoutePosition(positionId:3,lat:20.5290,lng:-100.8320,speed:20,timestamp:'14:30'),
RoutePosition(positionId:4,lat:20.5315,lng:-100.8355,speed:15,timestamp:'14:45'),
RoutePosition(positionId:5,lat:20.5340,lng:-100.8390,speed:0,timestamp:'15:00'),
RoutePosition(positionId:6,lat:20.5362,lng:-100.8425,speed:10,timestamp:'15:15'),
RoutePosition(positionId:7,lat:20.5330,lng:-100.8430,speed:18,timestamp:'15:28'),
RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:35,timestamp:'15:45'),
]),
RouteModel(routeId:'RUTA-04',name:'Oriente - Los Olivos',truckId:104,status:'EN_RUTA',turno:'MATUTINO',positions:[
RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'06:15'),
RoutePosition(positionId:2,lat:20.5260,lng:-100.8010,speed:45,timestamp:'06:30'),
RoutePosition(positionId:3,lat:20.5295,lng:-100.7890,speed:24,timestamp:'06:45'),
RoutePosition(positionId:4,lat:20.5320,lng:-100.7850,speed:12,timestamp:'06:58'),
RoutePosition(positionId:5,lat:20.5350,lng:-100.7790,speed:0,timestamp:'07:12'),
RoutePosition(positionId:6,lat:20.5310,lng:-100.7760,speed:15,timestamp:'07:25'),
RoutePosition(positionId:7,lat:20.5270,lng:-100.7820,speed:26,timestamp:'07:38'),
RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:48,timestamp:'07:58'),
]),
RouteModel(routeId:'RUTA-05',name:'Sector Sur - Rancho Seco',truckId:105,status:'EN_RUTA',turno:'VESPERTINO',positions:[
RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'14:20'),
RoutePosition(positionId:2,lat:20.5050,lng:-100.8620,speed:35,timestamp:'14:32'),
RoutePosition(positionId:3,lat:20.5020,lng:-100.8350,speed:22,timestamp:'14:45'),
RoutePosition(positionId:4,lat:20.4995,lng:-100.8210,speed:14,timestamp:'14:58'),
RoutePosition(positionId:5,lat:20.4970,lng:-100.8150,speed:0,timestamp:'15:10'),
RoutePosition(positionId:6,lat:20.5010,lng:-100.8120,speed:16,timestamp:'15:22'),
RoutePosition(positionId:7,lat:20.5060,lng:-100.8160,speed:25,timestamp:'15:35'),
RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:40,timestamp:'15:55'),
]),
RouteModel(routeId:'RUTA-06',name:'Norte Extremo - Rumbos de Roque',truckId:106,status:'EN_RUTA',turno:'MATUTINO',positions:[
RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'06:00'),
RoutePosition(positionId:2,lat:20.5380,lng:-100.8380,speed:40,timestamp:'06:15'),
RoutePosition(positionId:3,lat:20.5610,lng:-100.8370,speed:30,timestamp:'06:30'),
RoutePosition(positionId:4,lat:20.5750,lng:-100.8360,speed:15,timestamp:'06:45'),
RoutePosition(positionId:5,lat:20.5820,lng:-100.8350,speed:0,timestamp:'07:00'),
RoutePosition(positionId:6,lat:20.5780,lng:-100.8310,speed:20,timestamp:'07:15'),
RoutePosition(positionId:7,lat:20.5650,lng:-100.8320,speed:28,timestamp:'07:30'),
RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:45,timestamp:'07:55'),
]),
RouteModel(routeId:'RUTA-07',name:'Nororiente - Ciudad Industrial',truckId:107,status:'EN_RUTA',turno:'MATUTINO',positions:[
RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'06:10'),
RoutePosition(positionId:2,lat:20.5350,lng:-100.8050,speed:44,timestamp:'06:24'),
RoutePosition(positionId:3,lat:20.5450,lng:-100.7950,speed:25,timestamp:'06:38'),
RoutePosition(positionId:4,lat:20.5480,lng:-100.7850,speed:18,timestamp:'06:52'),
RoutePosition(positionId:5,lat:20.5510,lng:-100.7750,speed:0,timestamp:'07:05'),
RoutePosition(positionId:6,lat:20.5460,lng:-100.7720,speed:12,timestamp:'07:18'),
RoutePosition(positionId:7,lat:20.5390,lng:-100.7820,speed:30,timestamp:'07:30'),
RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:42,timestamp:'07:52'),
]),
RouteModel(routeId:'RUTA-08',name:'Suroriente - Universidad Latina',truckId:108,status:'EN_RUTA',turno:'NOCTURNO',positions:[
RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'22:00'),
RoutePosition(positionId:2,lat:20.5180,lng:-100.8310,speed:38,timestamp:'22:15'),
RoutePosition(positionId:3,lat:20.5245,lng:-100.7980,speed:30,timestamp:'22:30'),
RoutePosition(positionId:4,lat:20.5210,lng:-100.7995,speed:14,timestamp:'22:45'),
RoutePosition(positionId:5,lat:20.5175,lng:-100.8010,speed:0,timestamp:'23:00'),
RoutePosition(positionId:6,lat:20.5140,lng:-100.8030,speed:18,timestamp:'23:15'),
RoutePosition(positionId:7,lat:20.5110,lng:-100.8055,speed:22,timestamp:'23:30'),
RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:40,timestamp:'23:50'),
]),
RouteModel(routeId:'RUTA-09',name:'Poniente - Hospital General',truckId:109,status:'EN_RUTA',turno:'MATUTINO',positions:[
RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'06:02'),
RoutePosition(positionId:2,lat:20.5210,lng:-100.8650,speed:45,timestamp:'06:12'),
RoutePosition(positionId:3,lat:20.5260,lng:-100.8520,speed:26,timestamp:'06:24'),
RoutePosition(positionId:4,lat:20.5275,lng:-100.8490,speed:12,timestamp:'06:36'),
RoutePosition(positionId:5,lat:20.5285,lng:-100.8460,speed:0,timestamp:'06:48'),
RoutePosition(positionId:6,lat:20.5250,lng:-100.8470,speed:15,timestamp:'07:00'),
RoutePosition(positionId:7,lat:20.5220,lng:-100.8550,speed:32,timestamp:'07:12'),
RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:44,timestamp:'07:30'),
]),
RouteModel(routeId:'RUTA-10',name:'Eje Juan Pablo II - UG Sur',truckId:110,status:'EN_RUTA',turno:'NOCTURNO',positions:[
RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'21:00'),
RoutePosition(positionId:2,lat:20.5015,lng:-100.8520,speed:40,timestamp:'21:15'),
RoutePosition(positionId:3,lat:20.4990,lng:-100.8390,speed:28,timestamp:'21:30'),
RoutePosition(positionId:4,lat:20.4950,lng:-100.8320,speed:18,timestamp:'21:45'),
RoutePosition(positionId:5,lat:20.4920,lng:-100.8280,speed:0,timestamp:'22:00'),
RoutePosition(positionId:6,lat:20.4945,lng:-100.8240,speed:14,timestamp:'22:15'),
RoutePosition(positionId:7,lat:20.4980,lng:-100.8300,speed:30,timestamp:'22:30'),
RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:38,timestamp:'22:50'),
]),
RouteModel(routeId:'RUTA-11',name:'Zona de Oro - Torres Landa',truckId:111,status:'EN_RUTA',turno:'MATUTINO',positions:[
RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'06:04'),
RoutePosition(positionId:2,lat:20.5240,lng:-100.8350,speed:36,timestamp:'06:16'),
RoutePosition(positionId:3,lat:20.5280,lng:-100.8250,speed:22,timestamp:'06:29'),
RoutePosition(positionId:4,lat:20.5295,lng:-100.8210,speed:10,timestamp:'06:42'),
RoutePosition(positionId:5,lat:20.5310,lng:-100.8170,speed:0,timestamp:'06:55'),
RoutePosition(positionId:6,lat:20.5290,lng:-100.8140,speed:16,timestamp:'07:08'),
RoutePosition(positionId:7,lat:20.5260,lng:-100.8220,speed:28,timestamp:'07:21'),
RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:42,timestamp:'07:42'),
]),
RouteModel(routeId:'RUTA-12',name:'Nororiente - Las Insurgentes',truckId:112,status:'EN_RUTA',turno:'MATUTINO',positions:[
RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'06:08'),
RoutePosition(positionId:2,lat:20.5280,lng:-100.8080,speed:40,timestamp:'06:22'),
RoutePosition(positionId:3,lat:20.5320,lng:-100.7980,speed:24,timestamp:'06:35'),
RoutePosition(positionId:4,lat:20.5340,lng:-100.7940,speed:15,timestamp:'06:48'),
RoutePosition(positionId:5,lat:20.5360,lng:-100.7900,speed:0,timestamp:'07:00'),
RoutePosition(positionId:6,lat:20.5310,lng:-100.7920,speed:12,timestamp:'07:12'),
RoutePosition(positionId:7,lat:20.5270,lng:-100.8020,speed:26,timestamp:'07:25'),
RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:44,timestamp:'07:48'),
]),
RouteModel(routeId:'RUTA-13',name:'Sector Norte - Trojes e Irrigación',truckId:113,status:'EN_RUTA',turno:'MATUTINO',positions:[
RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'06:12'),
RoutePosition(positionId:2,lat:20.5360,lng:-100.8190,speed:35,timestamp:'06:26'),
RoutePosition(positionId:3,lat:20.5420,lng:-100.8080,speed:28,timestamp:'06:40'),
RoutePosition(positionId:4,lat:20.5440,lng:-100.8040,speed:14,timestamp:'06:54'),
RoutePosition(positionId:5,lat:20.5460,lng:-100.8000,speed:0,timestamp:'07:06'),
RoutePosition(positionId:6,lat:20.5410,lng:-100.8020,speed:18,timestamp:'07:18'),
RoutePosition(positionId:7,lat:20.5370,lng:-100.8120,speed:25,timestamp:'07:30'),
RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:39,timestamp:'07:54'),
]),
RouteModel(routeId:'RUTA-14',name:'Sur Poniente - La Toscana',truckId:114,status:'EN_RUTA',turno:'VESPERTINO',positions:[
RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'14:16'),
RoutePosition(positionId:2,lat:20.5150,lng:-100.8580,speed:42,timestamp:'14:28'),
RoutePosition(positionId:3,lat:20.5140,lng:-100.8390,speed:26,timestamp:'14:41'),
RoutePosition(positionId:4,lat:20.5125,lng:-100.8310,speed:16,timestamp:'14:54'),
RoutePosition(positionId:5,lat:20.5110,lng:-100.8250,speed:0,timestamp:'15:06'),
RoutePosition(positionId:6,lat:20.5135,lng:-100.8280,speed:12,timestamp:'15:18'),
RoutePosition(positionId:7,lat:20.5160,lng:-100.8420,speed:32,timestamp:'15:30'),
RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:45,timestamp:'15:51'),
]),
RouteModel(routeId:'RUTA-15',name:'Norponiente - San José de Celaya',truckId:115,status:'EN_RUTA',turno:'NOCTURNO',positions:[
RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'22:30'),
RoutePosition(positionId:2,lat:20.5320,lng:-100.8590,speed:38,timestamp:'22:45'),
RoutePosition(positionId:3,lat:20.5390,lng:-100.8480,speed:24,timestamp:'23:00'),
RoutePosition(positionId:4,lat:20.5420,lng:-100.8440,speed:15,timestamp:'23:15'),
RoutePosition(positionId:5,lat:20.5450,lng:-100.8410,speed:0,timestamp:'23:30'),
RoutePosition(positionId:6,lat:20.5410,lng:-100.8430,speed:14,timestamp:'23:45'),
RoutePosition(positionId:7,lat:20.5360,lng:-100.8520,speed:28,timestamp:'00:00'),
RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:41,timestamp:'00:20'),
]),
];
RouteModel? getRouteById(String id) {
try { return routesData.firstWhere((r) => r.routeId == id); }
catch (_) { return null; }
}

View File

@@ -0,0 +1,388 @@
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import '../models/models.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_v3.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, alias TEXT DEFAULT 'Casa',
calle TEXT NOT NULL, colonia TEXT NOT NULL,
route_id TEXT NOT NULL, horario_estimado TEXT NOT NULL,
is_primary INTEGER DEFAULT 0)''');
await db.execute('''CREATE TABLE route_definitions(
id INTEGER PRIMARY KEY AUTOINCREMENT,
route_id TEXT UNIQUE NOT NULL, nombre TEXT NOT NULL,
dias TEXT NOT NULL, hora_inicio TEXT NOT NULL,
hora_fin TEXT NOT NULL, turno TEXT NOT NULL,
colonias TEXT NOT NULL, activa INTEGER DEFAULT 1)''');
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 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 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,
foto_path TEXT)''');
await db.execute('''CREATE TABLE reviews(
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, colonia TEXT NOT NULL,
route_id TEXT NOT NULL, estrellas INTEGER NOT NULL,
comentario TEXT NOT NULL, fecha TEXT NOT NULL,
nombre_usuario TEXT DEFAULT 'Ciudadano')''');
await db.execute('''CREATE TABLE notification_history(
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER, route_id TEXT NOT NULL,
event_type TEXT NOT NULL, title TEXT NOT NULL,
body TEXT NOT NULL, fecha TEXT NOT NULL,
leida INTEGER DEFAULT 0)''');
await db.execute('''CREATE TABLE user_meta(
user_id INTEGER PRIMARY KEY, activo INTEGER DEFAULT 1,
notas TEXT)''');
await db.insert('users', {'nombre':'Administrador','email':'admin@celaya.gob.mx',
'password':'admin123','rol':'ADMINISTRADOR'});
final conductorId = await db.insert('users', {'nombre':'Juan Conductor',
'email':'conductor@celaya.gob.mx','password':'conductor123','rol':'CONDUCTOR'});
await db.insert('user_meta', {'user_id': conductorId, 'activo': 1});
}
// ── 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 {
final db = await database;
final existing = await db.query('domicilios', where:'user_id=?', whereArgs:[d.userId]);
final isPrimary = existing.isEmpty ? 1 : (d.isPrimary ? 1 : 0);
return db.insert('domicilios', {...d.toMap(), 'is_primary': isPrimary});
}
static Future<List<DomicilioModel>> getDomiciliosByUser(int userId) async {
final res = await (await database).query('domicilios',
where:'user_id=?', whereArgs:[userId], orderBy:'is_primary DESC, id ASC');
return res.map((m) => DomicilioModel.fromMap(m)).toList();
}
static Future<DomicilioModel?> getPrimaryDomicilio(int userId) async {
final db = await database;
var res = await db.query('domicilios',
where:'user_id=? AND is_primary=1', whereArgs:[userId]);
if (res.isEmpty) {
res = await db.query('domicilios', where:'user_id=?', whereArgs:[userId], limit:1);
}
return res.isEmpty ? null : DomicilioModel.fromMap(res.first);
}
static Future<void> setPrimaryDomicilio(int domId, int userId) async {
final db = await database;
await db.update('domicilios', {'is_primary':0}, where:'user_id=?', whereArgs:[userId]);
await db.update('domicilios', {'is_primary':1}, where:'id=?', whereArgs:[domId]);
}
static Future<void> deleteDomicilio(int id) async =>
(await database).delete('domicilios', where:'id=?', whereArgs:[id]);
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();
}
// ── ROUTE DEFINITIONS ────────────────────────────────────────────────────
static Future<int> insertRouteDefinition(RouteDefinitionModel r) async =>
(await database).insert('route_definitions', r.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace);
static Future<List<RouteDefinitionModel>> getAllRouteDefinitions() async {
final res = await (await database).query('route_definitions', orderBy:'route_id ASC');
return res.map((m) => RouteDefinitionModel.fromMap(m)).toList();
}
static Future<RouteDefinitionModel?> getRouteDefinitionById(String routeId) async {
final res = await (await database).query('route_definitions',
where:'route_id=?', whereArgs:[routeId]);
return res.isEmpty ? null : RouteDefinitionModel.fromMap(res.first);
}
static Future<void> updateRouteDefinition(RouteDefinitionModel r) async =>
(await database).update('route_definitions', r.toMap(),
where:'route_id=?', whereArgs:[r.routeId]);
// ── ROUTE STATUS ─────────────────────────────────────────────────────────
static Future<void> upsertRouteStatus(RouteStatusModel s) async =>
(await database).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();
}
// ── 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 id) async {
final res = await (await database).query('asignaciones',
where:'conductor_id=?', whereArgs:[id]);
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();
}
// ── 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<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();
}
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>> getAllReportes() async {
final res = await (await database).query('reportes', orderBy:'fecha DESC');
return res.map((m) => ReporteModel.fromMap(m)).toList();
}
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<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''');
}
static Future<void> updateReporteEstado(int id, String estado) async =>
(await database).update('reportes', {'estado':estado}, where:'id=?', whereArgs:[id]);
// ── REVIEWS ──────────────────────────────────────────────────────────────
static Future<int> insertReview(ReviewModel r) async =>
(await database).insert('reviews', r.toMap());
static Future<List<ReviewModel>> getAllReviews() async {
final res = await (await database).query('reviews', orderBy:'fecha DESC');
return res.map((m) => ReviewModel.fromMap(m)).toList();
}
static Future<bool> hasReviewedRoute(int userId, String routeId) async {
final today = DateTime.now().toIso8601String().substring(0, 10);
final res = await (await database).query('reviews',
where:"user_id=? AND route_id=? AND fecha LIKE '$today%'",
whereArgs:[userId, routeId]);
return res.isNotEmpty;
}
static Future<List<Map<String, dynamic>>> getReviewSummaryByColonia() async {
final db = await database;
return db.rawQuery('''
SELECT colonia, route_id,
AVG(estrellas) as promedio,
COUNT(*) as total,
MIN(estrellas) as min_est,
MAX(estrellas) as max_est
FROM reviews GROUP BY colonia ORDER BY promedio ASC''');
}
// ── NOTIFICATION HISTORY ─────────────────────────────────────────────────
static Future<int> insertNotifHistory({
int? userId, required String routeId, required String eventType,
required String title, required String body,
}) async => (await database).insert('notification_history', {
'user_id': userId, 'route_id': routeId, 'event_type': eventType,
'title': title, 'body': body,
'fecha': DateTime.now().toIso8601String(), 'leida': 0,
});
static Future<List<Map<String, dynamic>>> getNotifHistory(int userId) async =>
(await database).query('notification_history',
where: 'user_id IS NULL OR user_id = ?', whereArgs: [userId],
orderBy: 'fecha DESC', limit: 50);
static Future<int> countUnreadNotifs(int userId) async {
final res = await (await database).rawQuery(
'SELECT COUNT(*) as c FROM notification_history WHERE (user_id IS NULL OR user_id=?) AND leida=0',
[userId]);
return (res.first['c'] as int? ?? 0);
}
static Future<void> markAllNotifsRead(int userId) async =>
(await database).update('notification_history', {'leida': 1},
where: 'user_id IS NULL OR user_id = ?', whereArgs: [userId]);
// ── CONDUCTORES CON METADATA ─────────────────────────────────────────────
static Future<List<Map<String, dynamic>>> getConductoresConMeta() async {
final db = await database;
return db.rawQuery('''
SELECT u.*, COALESCE(m.activo, 1) as activo, m.notas,
(SELECT COUNT(*) FROM alertas a
WHERE a.tipo LIKE 'INCIDENTE_%'
AND a.route_id IN (
SELECT route_id FROM asignaciones WHERE conductor_id = u.id
)) as total_incidentes
FROM users u
LEFT JOIN user_meta m ON m.user_id = u.id
WHERE u.rol = 'CONDUCTOR'
ORDER BY u.nombre ASC''');
}
static Future<void> updateConductorMeta(int userId, bool activo, String notas) async {
final db = await database;
final ex = await db.query('user_meta', where:'user_id=?', whereArgs:[userId]);
if (ex.isEmpty) {
await db.insert('user_meta', {'user_id':userId,'activo':activo?1:0,'notas':notas});
} else {
await db.update('user_meta', {'activo':activo?1:0,'notas':notas},
where:'user_id=?', whereArgs:[userId]);
}
}
static Future<int> insertConductor(String nombre, String email, String password) async {
final db = await database;
final uid = await db.insert('users',
{'nombre':nombre,'email':email,'password':password,'rol':'CONDUCTOR'},
conflictAlgorithm: ConflictAlgorithm.abort);
await db.insert('user_meta', {'user_id':uid,'activo':1});
return uid;
}
static Future<void> updateConductor(int id, String nombre, String email) async =>
(await database).update('users', {'nombre':nombre,'email':email},
where:'id=?', whereArgs:[id]);
// ── ESTADÍSTICAS ─────────────────────────────────────────────────────────
static Future<Map<String, dynamic>> getAdminStats() async {
final db = await database;
final totalReportes = (await db.rawQuery('SELECT COUNT(*) as c FROM reportes')).first['c'];
final totalReviews = (await db.rawQuery('SELECT COUNT(*) as c FROM reviews')).first['c'];
final avgRating = (await db.rawQuery('SELECT AVG(estrellas) as a FROM reviews')).first['a'];
final totalAlertas = (await db.rawQuery('SELECT COUNT(*) as c FROM alertas WHERE resuelta=0')).first['c'];
final totalConductores = (await db.rawQuery(
"SELECT COUNT(*) as c FROM users WHERE rol='CONDUCTOR'")).first['c'];
return {
'total_reportes': totalReportes ?? 0,
'total_reviews': totalReviews ?? 0,
'avg_rating': (avgRating as num?)?.toDouble() ?? 0.0,
'alertas_activas': totalAlertas ?? 0,
'total_conductores': totalConductores ?? 0,
};
}
static Future<List<Map<String, dynamic>>> getReportesByColonia() async {
final db = await database;
return db.rawQuery('''
SELECT colonia, COUNT(*) as total,
SUM(CASE WHEN estado='RESUELTO' THEN 1 ELSE 0 END) as resueltos
FROM reportes GROUP BY colonia ORDER BY total DESC LIMIT 10''');
}
static Future<List<Map<String, dynamic>>> getIncidentesByRoute() async {
final db = await database;
return db.rawQuery('''
SELECT route_id, COUNT(*) as total
FROM alertas WHERE tipo LIKE 'INCIDENTE_%'
GROUP BY route_id ORDER BY total DESC LIMIT 10''');
}
static Future<List<Map<String, dynamic>>> getRatingByWeek() async {
final db = await database;
return db.rawQuery('''
SELECT strftime('%W', fecha) as semana,
AVG(estrellas) as promedio,
COUNT(*) as total
FROM reviews
GROUP BY semana ORDER BY semana DESC LIMIT 8''');
}
}

View File

@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'core/app_colors.dart';
import 'services/auth_service.dart';
import 'services/route_simulator_service.dart';
import 'services/theme_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';
import 'screens/onboarding/onboarding_screen.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final prefs = await SharedPreferences.getInstance();
final onboardingDone = prefs.getBool('onboarding_done') ?? false;
runApp(CelayaLimpiaApp(onboardingDone: onboardingDone));
}
class CelayaLimpiaApp extends StatelessWidget {
final bool onboardingDone;
const CelayaLimpiaApp({super.key, required this.onboardingDone});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => AuthService()),
ChangeNotifierProvider(create: (_) => RouteSimulatorService()),
ChangeNotifierProvider(create: (_) => ThemeService()),
],
child: Consumer<ThemeService>(
builder: (_, themeService, __) => MaterialApp(
title: 'Celaya Limpia',
debugShowCheckedModeBanner: false,
themeMode: themeService.themeMode,
theme: _lightTheme(),
darkTheme: _darkTheme(),
initialRoute: onboardingDone ? '/splash' : '/onboarding',
routes: {
'/onboarding': (_) => const OnboardingScreen(),
'/splash': (_) => const SplashScreen(),
'/login': (_) => const LoginScreen(),
'/register': (_) => const RegisterScreen(),
'/home': (_) => const CitizenHomeScreen(),
'/driver': (_) => const DriverHomeScreen(),
'/admin': (_) => const AdminDashboardScreen(),
},
),
),
);
}
ThemeData _lightTheme() => ThemeData(
useMaterial3: true,
brightness: Brightness.light,
colorScheme: ColorScheme.fromSeed(seedColor: AppColors.guindaPrimary,
primary: AppColors.guindaPrimary, secondary: AppColors.dorado),
scaffoldBackgroundColor: AppColors.grisFondo,
inputDecorationTheme: const InputDecorationTheme(
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: AppColors.guindaPrimary, width: 2)),
labelStyle: TextStyle(color: AppColors.guindaPrimary)),
);
ThemeData _darkTheme() => ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: ColorScheme.fromSeed(seedColor: AppColors.guindaLight,
brightness: Brightness.dark, primary: AppColors.guindaLight,
secondary: AppColors.dorado),
scaffoldBackgroundColor: const Color(0xFF121212),
cardColor: const Color(0xFF1E1E1E),
inputDecorationTheme: const InputDecorationTheme(
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: AppColors.guindaLight, width: 2)),
labelStyle: TextStyle(color: AppColors.guindaLight)),
);
}

View File

@@ -0,0 +1,186 @@
// ── USER ──────────────────────────────────────────────────────────────────
class UserModel {
final int? id;
final String nombre;
final String email;
final String password;
final String rol;
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 (User → Domicilio → Zona → Ruta) ────────────────────────────
class DomicilioModel {
final int? id;
final int userId;
final String alias; // "Casa", "Trabajo", etc.
final String calle;
final String colonia; // Zona de cobertura
final String routeId; // Ruta asignada
final String horarioEstimado;
final bool isPrimary;
DomicilioModel({this.id, required this.userId, this.alias = 'Casa',
required this.calle, required this.colonia, required this.routeId,
required this.horarioEstimado, this.isPrimary = true});
Map<String, dynamic> toMap() => {'id':id,'user_id':userId,'alias':alias,
'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'], alias:m['alias']??'Casa',
calle:m['calle'], colonia:m['colonia'], routeId:m['route_id'],
horarioEstimado:m['horario_estimado'], isPrimary:m['is_primary']==1);
}
// ── RUTA DINÁMICA (creada por admin) ──────────────────────────────────────
class RouteDefinitionModel {
final int? id;
final String routeId;
final String nombre;
final List<String> dias; // ['LUNES','MIERCOLES','VIERNES']
final String horaInicio; // '06:00'
final String horaFin; // '08:00'
final String turno; // MATUTINO|VESPERTINO|NOCTURNO
final List<String> colonias; // colonias que cubre
final bool activa;
RouteDefinitionModel({this.id, required this.routeId, required this.nombre,
required this.dias, required this.horaInicio, required this.horaFin,
required this.turno, required this.colonias, this.activa = true});
Map<String, dynamic> toMap() => {
'id':id,'route_id':routeId,'nombre':nombre,
'dias':dias.join(','),'hora_inicio':horaInicio,'hora_fin':horaFin,
'turno':turno,'colonias':colonias.join('|'),'activa':activa?1:0,
};
factory RouteDefinitionModel.fromMap(Map<String, dynamic> m) =>
RouteDefinitionModel(
id:m['id'], routeId:m['route_id'], nombre:m['nombre'],
dias:(m['dias']??'').toString().split(',').where((s)=>s.isNotEmpty).toList(),
horaInicio:m['hora_inicio']??'06:00', horaFin:m['hora_fin']??'08:00',
turno:m['turno']??'MATUTINO',
colonias:(m['colonias']??'').toString().split('|').where((s)=>s.isNotEmpty).toList(),
activa:m['activa']==1,
);
}
// ── RESEÑA DEL SERVICIO ───────────────────────────────────────────────────
class ReviewModel {
final int? id;
final int userId;
final String colonia;
final String routeId;
final int estrellas; // 1-5
final String comentario;
final String fecha;
final String nombreUsuario;
ReviewModel({this.id, required this.userId, required this.colonia,
required this.routeId, required this.estrellas, required this.comentario,
required this.fecha, this.nombreUsuario = ''});
Map<String, dynamic> toMap() => {'id':id,'user_id':userId,'colonia':colonia,
'route_id':routeId,'estrellas':estrellas,'comentario':comentario,
'fecha':fecha,'nombre_usuario':nombreUsuario};
factory ReviewModel.fromMap(Map<String, dynamic> m) => ReviewModel(
id:m['id'], userId:m['user_id'], colonia:m['colonia'],
routeId:m['route_id'], estrellas:m['estrellas'],
comentario:m['comentario']??'', fecha:m['fecha'],
nombreUsuario:m['nombre_usuario']??'Ciudadano');
}
// ── ASSIGNMENT ────────────────────────────────────────────────────────────
class AssignmentModel {
final int? id;
final int conductorId;
final String routeId;
final String diaSemana;
final String turno;
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;
final String routeId;
final String mensaje;
final String fecha;
final bool resuelta;
AlertaModel({this.id, required this.tipo, required this.routeId,
required this.mensaje, required this.fecha, this.resuelta=false});
Map<String, dynamic> toMap() => {'id':id,'tipo':tipo,'route_id':routeId,
'mensaje':mensaje,'fecha':fecha,'resuelta':resuelta?1:0};
factory AlertaModel.fromMap(Map<String, dynamic> m) => AlertaModel(
id:m['id'], tipo:m['tipo'], routeId:m['route_id'],
mensaje:m['mensaje'], fecha:m['fecha'], resuelta:m['resuelta']==1);
}
// ── REPORTE ───────────────────────────────────────────────────────────────
class ReporteModel {
final int? id;
final int userId;
final String tipo;
final String descripcion;
final String colonia;
final String routeId;
final String fecha;
final String estado;
final int calificacion;
ReporteModel({this.id, required this.userId, required this.tipo,
required this.descripcion, required this.colonia, required this.routeId,
required this.fecha, this.estado='PENDIENTE', this.calificacion=5});
Map<String, dynamic> toMap() => {'id':id,'user_id':userId,'tipo':tipo,
'descripcion':descripcion,'colonia':colonia,'route_id':routeId,
'fecha':fecha,'estado':estado,'calificacion':calificacion};
factory ReporteModel.fromMap(Map<String, dynamic> m) => ReporteModel(
id:m['id'], userId:m['user_id'], tipo:m['tipo'],
descripcion:m['descripcion'], colonia:m['colonia'],
routeId:m['route_id']??'', fecha:m['fecha'],
estado:m['estado'], calificacion:m['calificacion']??5);
}

View File

@@ -0,0 +1,39 @@
import 'package:latlong2/latlong.dart';
class RoutePosition {
final int positionId;
final double lat;
final double lng;
final int speed;
final String timestamp;
RoutePosition({required this.positionId, required this.lat,
required this.lng, required this.speed, required this.timestamp});
LatLng get latLng => LatLng(lat, lng);
}
class RouteModel {
final String routeId;
final String name;
final int truckId;
String status;
final List<RoutePosition> positions;
final String turno;
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});
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,262 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import '../../core/app_colors.dart';
import '../../database/db_helper.dart';
class AdminStatsScreen extends StatefulWidget {
const AdminStatsScreen({super.key});
@override State<AdminStatsScreen> createState() => _AdminStatsScreenState();
}
class _AdminStatsScreenState extends State<AdminStatsScreen> {
Map<String, dynamic> _stats = {};
List<Map<String, dynamic>> _byColonia = [];
List<Map<String, dynamic>> _byRoute = [];
List<Map<String, dynamic>> _byWeek = [];
bool _loading = true;
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final s = await DbHelper.getAdminStats();
final bc = await DbHelper.getReportesByColonia();
final br = await DbHelper.getIncidentesByRoute();
final bw = await DbHelper.getRatingByWeek();
if (mounted) setState(() {
_stats = s; _byColonia = bc; _byRoute = br; _byWeek = bw; _loading = false;
});
}
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
title: const Text('Dashboard de Estadisticas'),
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())
: SingleChildScrollView(padding: const EdgeInsets.all(14), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
// KPIs
Row(children: [
_KpiCard('Reportes', '${_stats['total_reportes']}',
Icons.report, AppColors.naranjaAlerta),
const SizedBox(width: 8),
_KpiCard('Calificacion Prom.',
(_stats['avg_rating'] as double? ?? 0).toStringAsFixed(1),
Icons.star, Colors.amber),
]),
const SizedBox(height: 8),
Row(children: [
_KpiCard('Alertas Activas', '${_stats['alertas_activas']}',
Icons.warning, AppColors.rojoError),
const SizedBox(width: 8),
_KpiCard('Conductores', '${_stats['total_conductores']}',
Icons.person, AppColors.moradoConductor),
]),
const SizedBox(height: 20),
// Calificacion por semana (línea)
if (_byWeek.isNotEmpty) ...[
_SectionTitle('Calificacion promedio semanal'),
const SizedBox(height: 8),
Card(child: Padding(padding: const EdgeInsets.all(16),
child: SizedBox(height: 180,
child: LineChart(LineChartData(
minY: 1, maxY: 5,
titlesData: FlTitlesData(
leftTitles: AxisTitles(sideTitles: SideTitles(
showTitles: true, interval: 1,
getTitlesWidget: (v,_) => Text(v.toInt().toString(),
style: const TextStyle(fontSize: 10)))),
bottomTitles: AxisTitles(sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (v, _) {
final idx = v.toInt();
if (idx < 0 || idx >= _byWeek.length) return const SizedBox();
return Text('S${_byWeek.length - idx}',
style: const TextStyle(fontSize: 9));
})),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
gridData: FlGridData(drawHorizontalLine: true, horizontalInterval: 1),
borderData: FlBorderData(show: true,
border: Border.all(color: Colors.grey.shade300)),
lineBarsData: [LineChartBarData(
spots: _byWeek.reversed.toList().asMap().entries.map((e) =>
FlSpot(e.key.toDouble(),
(e.value['promedio'] as num? ?? 0).toDouble().clamp(1.0, 5.0))).toList(),
isCurved: true,
color: AppColors.verdeAdmin,
barWidth: 3,
belowBarData: BarAreaData(show: true,
color: AppColors.verdeAdmin.withOpacity(0.1)),
dotData: const FlDotData(show: true),
)],
))))),
const SizedBox(height: 20),
],
// Reportes por colonia (barras horizontales)
if (_byColonia.isNotEmpty) ...[
_SectionTitle('Reportes por colonia (Top 10)'),
const SizedBox(height: 8),
Card(child: Padding(padding: const EdgeInsets.all(16),
child: SizedBox(height: 240,
child: BarChart(BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: (_byColonia.map((c) => (c['total'] as int? ?? 0).toDouble())
.reduce((a,b)=>a>b?a:b) * 1.2),
titlesData: FlTitlesData(
bottomTitles: AxisTitles(sideTitles: SideTitles(
showTitles: true, reservedSize: 32,
getTitlesWidget: (v, _) {
final i = v.toInt();
if (i < 0 || i >= _byColonia.length) return const SizedBox();
final name = (_byColonia[i]['colonia'] as String? ?? '');
return Transform.rotate(angle: -0.5,
child: Text(name.length > 8 ? '${name.substring(0,8)}.' : name,
style: const TextStyle(fontSize: 8)));
})),
leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: true,
getTitlesWidget: (v,_) => Text(v.toInt().toString(),
style: const TextStyle(fontSize: 9)))),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
barGroups: _byColonia.asMap().entries.map((e) => BarChartGroupData(
x: e.key,
barRods: [
BarChartRodData(
toY: (e.value['total'] as int? ?? 0).toDouble(),
color: AppColors.guindaPrimary,
width: 16, borderRadius: BorderRadius.circular(4)),
BarChartRodData(
toY: (e.value['resueltos'] as int? ?? 0).toDouble(),
color: AppColors.verdeExito,
width: 16, borderRadius: BorderRadius.circular(4)),
],
)).toList(),
gridData: const FlGridData(drawHorizontalLine: true),
borderData: FlBorderData(show: true,
border: Border.all(color: Colors.grey.shade300)),
))))),
Padding(padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(children: [
_Legend(AppColors.guindaPrimary, 'Total reportes'),
const SizedBox(width: 16),
_Legend(AppColors.verdeExito, 'Resueltos'),
])),
const SizedBox(height: 20),
],
// Rutas con más incidentes
if (_byRoute.isNotEmpty) ...[
_SectionTitle('Rutas con mas incidentes'),
const SizedBox(height: 8),
Card(child: Padding(padding: const EdgeInsets.all(16),
child: SizedBox(height: 200,
child: BarChart(BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: (_byRoute.map((r) => (r['total'] as int? ?? 0).toDouble())
.reduce((a,b)=>a>b?a:b) * 1.3),
titlesData: FlTitlesData(
bottomTitles: AxisTitles(sideTitles: SideTitles(
showTitles: true, reservedSize: 28,
getTitlesWidget: (v, _) {
final i = v.toInt();
if (i < 0 || i >= _byRoute.length) return const SizedBox();
return Text((_byRoute[i]['route_id'] as String? ?? '').replaceAll('RUTA-','R'),
style: const TextStyle(fontSize: 9));
})),
leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: true,
getTitlesWidget: (v,_) => Text(v.toInt().toString(),
style: const TextStyle(fontSize: 9)))),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
barGroups: _byRoute.asMap().entries.map((e) => BarChartGroupData(
x: e.key,
barRods: [BarChartRodData(
toY: (e.value['total'] as int? ?? 0).toDouble(),
gradient: const LinearGradient(
colors: [AppColors.naranjaAlerta, AppColors.rojoError],
begin: Alignment.bottomCenter, end: Alignment.topCenter),
width: 20, borderRadius: BorderRadius.circular(4))],
)).toList(),
gridData: const FlGridData(drawHorizontalLine: true),
borderData: FlBorderData(show: true,
border: Border.all(color: Colors.grey.shade300)),
))))),
const SizedBox(height: 20),
],
// Colonias más problemáticas (lista)
_SectionTitle('Colonias mas problematicas'),
const SizedBox(height: 8),
Card(child: Column(children: [
..._byColonia.take(5).map((c) {
final total = (c['total'] as int? ?? 0);
final resueltos = (c['resueltos'] as int? ?? 0);
final pct = total > 0 ? resueltos / total : 0.0;
return ListTile(dense: true,
leading: CircleAvatar(radius: 16,
backgroundColor: total > 3 ? AppColors.rojoError.withOpacity(0.15)
: AppColors.naranjaAlerta.withOpacity(0.15),
child: Text('$total', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold,
color: total > 3 ? AppColors.rojoError : AppColors.naranjaAlerta))),
title: Text(c['colonia'] as String? ?? '',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)),
subtitle: LinearProgressIndicator(value: pct,
backgroundColor: Colors.grey.shade200,
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.verdeExito)),
trailing: Text('${(pct*100).toInt()}% resuelto',
style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
);
}),
])),
const SizedBox(height: 30),
])),
);
}
class _KpiCard extends StatelessWidget {
final String label, value; final IconData icon; final Color color;
const _KpiCard(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: [
CircleAvatar(radius: 22, backgroundColor: color.withOpacity(0.12),
child: Icon(icon, color: color, size: 22)),
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: 10, color: AppColors.grisTexto)),
]),
]))));
}
class _SectionTitle extends StatelessWidget {
final String title;
const _SectionTitle(this.title);
@override
Widget build(BuildContext context) => Text(title,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15, color: AppColors.verdeAdmin));
}
class _Legend extends StatelessWidget {
final Color color; final String label;
const _Legend(this.color, this.label);
@override
Widget build(BuildContext context) => Row(mainAxisSize: MainAxisSize.min, children: [
Container(width: 12, height: 12, decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(2))),
const SizedBox(width: 4),
Text(label, style: const TextStyle(fontSize: 11, color: AppColors.grisTexto)),
]);
}

View File

@@ -0,0 +1,272 @@
import 'package:flutter/material.dart';
import '../../core/app_colors.dart';
import '../../data/celaya_colonias.dart';
import '../../database/db_helper.dart';
import '../../models/models.dart';
class CreateRouteScreen extends StatefulWidget {
final RouteDefinitionModel? editing;
const CreateRouteScreen({super.key, this.editing});
@override State<CreateRouteScreen> createState() => _CreateRouteScreenState();
}
class _CreateRouteScreenState extends State<CreateRouteScreen> {
final _nombreCtrl = TextEditingController();
final _routeIdCtrl = TextEditingController();
String _turno = 'MATUTINO';
String _horaInicio = '06:00';
String _horaFin = '08:00';
List<String> _diasSeleccionados = [];
List<String> _coloniasSeleccionadas = [];
String _searchColonia = '';
bool _loading = false;
static const _diasGrupoA = ['LUNES', 'MIERCOLES', 'VIERNES'];
static const _diasGrupoB = ['MARTES', 'JUEVES', 'SABADO'];
@override
void initState() {
super.initState();
if (widget.editing != null) {
final e = widget.editing!;
_nombreCtrl.text = e.nombre;
_routeIdCtrl.text = e.routeId;
_turno = e.turno;
_horaInicio = e.horaInicio;
_horaFin = e.horaFin;
_diasSeleccionados = List.from(e.dias);
_coloniasSeleccionadas = List.from(e.colonias);
}
}
List<String> get _filteredColonias => _searchColonia.isEmpty
? celayaColonias
: celayaColonias.where((c) =>
c.toLowerCase().contains(_searchColonia.toLowerCase())).toList();
Future<void> _guardar() async {
if (_nombreCtrl.text.trim().isEmpty) {
_snack('Ingresa un nombre para la ruta', isError: true); return; }
if (_routeIdCtrl.text.trim().isEmpty) {
_snack('Ingresa el ID de la ruta (ej. RUTA-16)', isError: true); return; }
if (_diasSeleccionados.isEmpty) {
_snack('Selecciona al menos un día', isError: true); return; }
if (_coloniasSeleccionadas.isEmpty) {
_snack('Selecciona al menos una colonia', isError: true); return; }
setState(() => _loading = true);
final route = RouteDefinitionModel(
id: widget.editing?.id,
routeId: _routeIdCtrl.text.trim().toUpperCase(),
nombre: _nombreCtrl.text.trim(),
dias: _diasSeleccionados,
horaInicio: _horaInicio,
horaFin: _horaFin,
turno: _turno,
colonias: _coloniasSeleccionadas,
);
await DbHelper.insertRouteDefinition(route);
if (!mounted) return;
setState(() => _loading = false);
_snack('Ruta guardada correctamente');
Navigator.pop(context, true);
}
void _snack(String msg, {bool isError = false}) =>
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(msg),
backgroundColor: isError ? AppColors.rojoError : AppColors.verdeExito));
Future<TimeOfDay?> _pickTime(String current) async {
final parts = current.split(':');
return showTimePicker(
context: context,
initialTime: TimeOfDay(hour: int.parse(parts[0]), minute: int.parse(parts[1])),
);
}
String _timeLabel(TimeOfDay t) =>
'${t.hour.toString().padLeft(2,'0')}:${t.minute.toString().padLeft(2,'0')}';
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
title: Text(widget.editing != null ? 'Editar Ruta' : 'Nueva Ruta'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// Info básica
_section('Información de la ruta'),
_field(_routeIdCtrl, 'ID de Ruta (ej. RUTA-16)', Icons.tag),
const SizedBox(height: 12),
_field(_nombreCtrl, 'Nombre descriptivo', Icons.route),
const SizedBox(height: 16),
// Turno
_section('Turno de operación'),
Row(children: ['MATUTINO','VESPERTINO','NOCTURNO'].map((t) =>
Expanded(child: RadioListTile<String>(dense: true, value: t,
groupValue: _turno,
title: Text(_turnoLabel(t), style: const TextStyle(fontSize: 12)),
activeColor: AppColors.verdeAdmin,
onChanged: (v) => setState(() => _turno = v!)))
).toList()),
const SizedBox(height: 8),
// Horario
_section('Horario de servicio'),
Row(children: [
Expanded(child: _timeButton('Hora inicio', _horaInicio, () async {
final t = await _pickTime(_horaInicio);
if (t != null) setState(() => _horaInicio = _timeLabel(t));
})),
const SizedBox(width: 12),
Expanded(child: _timeButton('Hora fin', _horaFin, () async {
final t = await _pickTime(_horaFin);
if (t != null) setState(() => _horaFin = _timeLabel(t));
})),
]),
const SizedBox(height: 16),
// Días
_section('Días de operación'),
Container(padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.shade200)),
child: const Text(
'📅 Selecciona Grupo A (L/M/V) o Grupo B (M/J/S), o días individuales.',
style: TextStyle(fontSize: 12, color: AppColors.azulInfo)),
),
const SizedBox(height: 8),
Row(children: [
Expanded(child: OutlinedButton(
onPressed: () => setState(() => _diasSeleccionados = List.from(_diasGrupoA)),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.verdeAdmin,
side: const BorderSide(color: AppColors.verdeAdmin)),
child: const Text('Grupo A\nL/M/V', textAlign: TextAlign.center,
style: TextStyle(fontSize: 11)))),
const SizedBox(width: 8),
Expanded(child: OutlinedButton(
onPressed: () => setState(() => _diasSeleccionados = List.from(_diasGrupoB)),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.moradoConductor,
side: const BorderSide(color: AppColors.moradoConductor)),
child: const Text('Grupo B\nM/J/S', textAlign: TextAlign.center,
style: TextStyle(fontSize: 11)))),
]),
const SizedBox(height: 8),
Wrap(spacing: 6, runSpacing: 6, children: AppDias.todos.map((dia) {
final sel = _diasSeleccionados.contains(dia);
return FilterChip(
label: Text(AppDias.label(dia), style: TextStyle(fontSize: 11,
color: sel ? Colors.white : AppColors.negroTexto)),
selected: sel,
selectedColor: AppColors.verdeAdmin,
checkmarkColor: Colors.white,
onSelected: (v) => setState(() {
if (v) _diasSeleccionados.add(dia);
else _diasSeleccionados.remove(dia);
}),
);
}).toList()),
const SizedBox(height: 16),
// Colonias
_section('Colonias que cubre (${_coloniasSeleccionadas.length} seleccionadas)'),
TextField(
onChanged: (v) => setState(() => _searchColonia = v),
decoration: const InputDecoration(
hintText: 'Buscar colonia de Celaya...',
prefixIcon: Icon(Icons.search), border: OutlineInputBorder(),
filled: true, fillColor: Colors.white, isDense: true),
),
const SizedBox(height: 8),
Container(height: 220,
decoration: BoxDecoration(color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300)),
child: ListView.builder(
itemCount: _filteredColonias.length,
itemBuilder: (_, i) {
final c = _filteredColonias[i];
final sel = _coloniasSeleccionadas.contains(c);
return CheckboxListTile(dense: true,
title: Text(c, style: const TextStyle(fontSize: 12)),
value: sel,
activeColor: AppColors.verdeAdmin,
controlAffinity: ListTileControlAffinity.leading,
onChanged: (v) => setState(() {
if (v == true) _coloniasSeleccionadas.add(c);
else _coloniasSeleccionadas.remove(c);
}),
);
},
),
),
if (_coloniasSeleccionadas.isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(spacing: 4, runSpacing: 4, children: _coloniasSeleccionadas.map((c) =>
Chip(label: Text(c, style: const TextStyle(fontSize: 10)),
backgroundColor: AppColors.verdeAdmin.withOpacity(0.1),
deleteIconColor: AppColors.verdeAdmin,
onDeleted: () => setState(() => _coloniasSeleccionadas.remove(c)))).toList()),
],
const SizedBox(height: 24),
SizedBox(width: double.infinity, height: 50,
child: ElevatedButton.icon(
onPressed: _loading ? null : _guardar,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.verdeAdmin, 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.save),
label: const Text('GUARDAR RUTA', style: TextStyle(fontWeight: FontWeight.bold)))),
const SizedBox(height: 30),
]),
),
);
}
Widget _section(String title) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(title, style: const TextStyle(fontWeight: FontWeight.bold,
color: AppColors.verdeAdmin, fontSize: 15)));
Widget _field(TextEditingController ctrl, String label, IconData icon) =>
TextField(controller: ctrl,
decoration: InputDecoration(labelText: label,
prefixIcon: Icon(icon, color: AppColors.verdeAdmin),
border: const OutlineInputBorder(), filled: true, fillColor: Colors.white));
Widget _timeButton(String label, String value, VoidCallback onTap) =>
InkWell(onTap: onTap,
child: Container(padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade400)),
child: Row(children: [
const Icon(Icons.access_time, color: AppColors.verdeAdmin, size: 18),
const SizedBox(width: 8),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
Text(value, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
]),
])));
String _turnoLabel(String t) => t == 'MATUTINO' ? '🌄 Matutino'
: t == 'VESPERTINO' ? '🌅 Vespertino' : '🌙 Nocturno';
@override void dispose() { _nombreCtrl.dispose(); _routeIdCtrl.dispose(); super.dispose(); }
}

View File

@@ -0,0 +1,244 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:share_plus/share_plus.dart';
import '../../core/app_colors.dart';
import '../../database/db_helper.dart';
class ExportPdfScreen extends StatefulWidget {
const ExportPdfScreen({super.key});
@override State<ExportPdfScreen> createState() => _ExportPdfScreenState();
}
class _ExportPdfScreenState extends State<ExportPdfScreen> {
bool _generating = false;
String? _lastPath;
pw.TableRow _pdfRow(String label, String value, {bool isHeader = false}) =>
pw.TableRow(
decoration: isHeader ? pw.BoxDecoration(color: PdfColors.grey100) : null,
children: [
pw.Padding(padding: const pw.EdgeInsets.all(6),
child: pw.Text(label, style: pw.TextStyle(
fontSize: 10, fontWeight: isHeader ? pw.FontWeight.bold : null))),
pw.Padding(padding: const pw.EdgeInsets.all(6),
child: pw.Text(value, style: pw.TextStyle(
fontSize: 10, fontWeight: pw.FontWeight.bold))),
]);
Future<void> _generatePdf() async {
setState(() => _generating = true);
try {
final stats = await DbHelper.getAdminStats();
final colonias = await DbHelper.getReportesByColonia();
final incidentes = await DbHelper.getIncidentesByRoute();
final reviews = await DbHelper.getReviewSummaryByColonia();
final now = DateTime.now();
const meses = ['','Enero','Febrero','Marzo','Abril','Mayo','Junio',
'Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'];
final pdf = pw.Document();
pdf.addPage(pw.MultiPage(
pageFormat: PdfPageFormat.a4,
margin: const pw.EdgeInsets.all(32),
header: (ctx) => pw.Column(children: [
pw.Row(mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [
pw.Column(crossAxisAlignment: pw.CrossAxisAlignment.start, children: [
pw.Text('H. AYUNTAMIENTO DE CELAYA', style: pw.TextStyle(
fontSize: 14, fontWeight: pw.FontWeight.bold,
color: PdfColor.fromHex('6D1E3A'))),
pw.Text('Direccion de Servicios Publicos',
style: const pw.TextStyle(fontSize: 11, color: PdfColors.grey700)),
pw.Text('Sistema de Recoleccion de Residuos',
style: const pw.TextStyle(fontSize: 10, color: PdfColors.grey600)),
]),
pw.Column(crossAxisAlignment: pw.CrossAxisAlignment.end, children: [
pw.Text('REPORTE MENSUAL', style: pw.TextStyle(
fontSize: 12, fontWeight: pw.FontWeight.bold,
color: PdfColor.fromHex('6D1E3A'))),
pw.Text('${meses[now.month]} ${now.year}',
style: const pw.TextStyle(fontSize: 11)),
pw.Text('Generado: ${now.day}/${now.month}/${now.year}',
style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey600)),
]),
]),
pw.Divider(color: PdfColor.fromHex('C9A84C'), thickness: 2),
pw.SizedBox(height: 8),
]),
build: (ctx) => [
pw.Text('RESUMEN EJECUTIVO', style: pw.TextStyle(
fontSize: 13, fontWeight: pw.FontWeight.bold,
color: PdfColor.fromHex('6D1E3A'))),
pw.SizedBox(height: 8),
pw.Table(
border: pw.TableBorder.all(color: PdfColors.grey300),
columnWidths: {0: const pw.FlexColumnWidth(2), 1: const pw.FlexColumnWidth(1)},
children: [
_pdfRow('Total de reportes ciudadanos', '${stats["total_reportes"]}', isHeader: true),
_pdfRow('Total de resenas recibidas', '${stats["total_reviews"]}'),
_pdfRow('Calificacion promedio',
'${(stats["avg_rating"] as double? ?? 0).toStringAsFixed(2)} / 5.0'),
_pdfRow('Alertas activas', '${stats["alertas_activas"]}'),
_pdfRow('Conductores', '${stats["total_conductores"]}'),
]),
pw.SizedBox(height: 20),
if (colonias.isNotEmpty) ...[
pw.Text('REPORTES POR COLONIA', style: pw.TextStyle(
fontSize: 13, fontWeight: pw.FontWeight.bold,
color: PdfColor.fromHex('6D1E3A'))),
pw.SizedBox(height: 8),
pw.Table(
border: pw.TableBorder.all(color: PdfColors.grey300),
columnWidths: {
0: const pw.FlexColumnWidth(3), 1: const pw.FlexColumnWidth(1),
2: const pw.FlexColumnWidth(1), 3: const pw.FlexColumnWidth(1),
},
children: [
pw.TableRow(
decoration: pw.BoxDecoration(color: PdfColor.fromHex('6D1E3A')),
children: ['Colonia','Total','Resueltos','Pendientes'].map((h) =>
pw.Padding(padding: const pw.EdgeInsets.all(6),
child: pw.Text(h, style: pw.TextStyle(color: PdfColors.white,
fontWeight: pw.FontWeight.bold, fontSize: 10)))).toList()),
...colonias.map((c) {
final total = c['total'] as int? ?? 0;
final res = c['resueltos'] as int? ?? 0;
return pw.TableRow(children: [
c['colonia'] as String? ?? '', '$total', '$res', '${total - res}',
].map((v) => pw.Padding(padding: const pw.EdgeInsets.all(5),
child: pw.Text(v, style: const pw.TextStyle(fontSize: 9)))).toList());
}),
]),
pw.SizedBox(height: 20),
],
if (incidentes.isNotEmpty) ...[
pw.Text('INCIDENTES POR RUTA', style: pw.TextStyle(
fontSize: 13, fontWeight: pw.FontWeight.bold,
color: PdfColor.fromHex('6D1E3A'))),
pw.SizedBox(height: 8),
pw.Table(
border: pw.TableBorder.all(color: PdfColors.grey300),
columnWidths: {0: const pw.FlexColumnWidth(2), 1: const pw.FlexColumnWidth(1)},
children: [
pw.TableRow(decoration: pw.BoxDecoration(color: PdfColor.fromHex('6D1E3A')),
children: ['Ruta','Incidentes'].map((h) => pw.Padding(
padding: const pw.EdgeInsets.all(6),
child: pw.Text(h, style: pw.TextStyle(color: PdfColors.white,
fontWeight: pw.FontWeight.bold, fontSize: 10)))).toList()),
...incidentes.map((r) => pw.TableRow(children: [
r['route_id'] as String? ?? '', '${r["total"]}',
].map((v) => pw.Padding(padding: const pw.EdgeInsets.all(5),
child: pw.Text(v, style: const pw.TextStyle(fontSize: 9)))).toList())),
]),
pw.SizedBox(height: 20),
],
if (reviews.isNotEmpty) ...[
pw.Text('CALIFICACIONES POR COLONIA', style: pw.TextStyle(
fontSize: 13, fontWeight: pw.FontWeight.bold,
color: PdfColor.fromHex('6D1E3A'))),
pw.SizedBox(height: 8),
pw.Table(
border: pw.TableBorder.all(color: PdfColors.grey300),
columnWidths: {0: const pw.FlexColumnWidth(3),
1: const pw.FlexColumnWidth(1), 2: const pw.FlexColumnWidth(1)},
children: [
pw.TableRow(decoration: pw.BoxDecoration(color: PdfColor.fromHex('6D1E3A')),
children: ['Colonia','Promedio','Total'].map((h) => pw.Padding(
padding: const pw.EdgeInsets.all(6),
child: pw.Text(h, style: pw.TextStyle(color: PdfColors.white,
fontWeight: pw.FontWeight.bold, fontSize: 10)))).toList()),
...reviews.map((r) => pw.TableRow(children: [
r['colonia'] as String? ?? '',
'${(r["promedio"] as num? ?? 0).toStringAsFixed(1)}/5',
'${r["total"]}',
].map((v) => pw.Padding(padding: const pw.EdgeInsets.all(5),
child: pw.Text(v, style: const pw.TextStyle(fontSize: 9)))).toList())),
]),
pw.SizedBox(height: 20),
],
pw.Divider(color: PdfColor.fromHex('C9A84C')),
pw.Text('Celaya Limpia - H. Ayuntamiento de Celaya, Gto. - ${now.year}',
style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey500),
textAlign: pw.TextAlign.center),
],
));
// Guardar en directorio temporal y compartir con share_plus
final bytes = await pdf.save();
final dir = await getTemporaryDirectory();
final file = File('${dir.path}/reporte_celaya_${now.month}_${now.year}.pdf');
await file.writeAsBytes(bytes);
setState(() => _lastPath = file.path);
await Share.shareXFiles(
[XFile(file.path, mimeType: 'application/pdf')],
subject: 'Reporte Mensual Celaya Limpia - ${meses[now.month]} ${now.year}',
);
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error al generar PDF: $e'),
backgroundColor: AppColors.rojoError));
}
if (mounted) setState(() => _generating = false);
}
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
title: const Text('Exportar Reporte PDF'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado))),
body: Center(child: Padding(padding: const EdgeInsets.all(32), child: Column(
mainAxisAlignment: MainAxisAlignment.center, children: [
Container(width: 100, height: 100,
decoration: BoxDecoration(color: AppColors.verdeAdmin.withOpacity(0.1),
shape: BoxShape.circle),
child: const Icon(Icons.picture_as_pdf, size: 52, color: AppColors.verdeAdmin)),
const SizedBox(height: 24),
const Text('Reporte Mensual', style: TextStyle(fontSize: 22,
fontWeight: FontWeight.bold, color: AppColors.verdeAdmin)),
const SizedBox(height: 8),
const Text('Genera un PDF con el resumen completo:\nreportes, incidentes y calificaciones.',
textAlign: TextAlign.center,
style: TextStyle(color: AppColors.grisTexto)),
const SizedBox(height: 32),
SizedBox(width: double.infinity, height: 52,
child: ElevatedButton.icon(
onPressed: _generating ? null : _generatePdf,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))),
icon: _generating
? const SizedBox(width: 20, height: 20,
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Icon(Icons.download),
label: Text(_generating ? 'Generando...' : 'Generar y Compartir PDF',
style: const TextStyle(fontWeight: FontWeight.bold)))),
if (_lastPath != null) ...[
const SizedBox(height: 16),
Container(padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: AppColors.verdeExito.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.verdeExito.withOpacity(0.3))),
child: Row(children: [
const Icon(Icons.check_circle, color: AppColors.verdeExito, size: 18),
const SizedBox(width: 8),
const Expanded(child: Text('PDF generado correctamente',
style: TextStyle(color: AppColors.verdeExito, fontWeight: FontWeight.w600,
fontSize: 13))),
TextButton(onPressed: _generatePdf,
child: const Text('Compartir de nuevo',
style: TextStyle(fontSize: 11, color: AppColors.verdeAdmin))),
])),
],
]))));
}

View File

@@ -0,0 +1,179 @@
import 'package:flutter/material.dart';
import '../../core/app_colors.dart';
import '../../database/db_helper.dart';
class ManageConductorsScreen extends StatefulWidget {
const ManageConductorsScreen({super.key});
@override State<ManageConductorsScreen> createState() => _ManageConductorsScreenState();
}
class _ManageConductorsScreenState extends State<ManageConductorsScreen> {
List<Map<String, dynamic>> _conductores = [];
bool _loading = true;
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final c = await DbHelper.getConductoresConMeta();
if (mounted) setState(() { _conductores = c; _loading = false; });
}
Future<void> _showFormDialog({Map<String, dynamic>? existing}) async {
final nombreCtrl = TextEditingController(text: existing?['nombre'] ?? '');
final emailCtrl = TextEditingController(text: existing?['email'] ?? '');
final passCtrl = TextEditingController();
final notasCtrl = TextEditingController(text: existing?['notas'] ?? '');
bool activo = (existing?['activo'] as int? ?? 1) == 1;
bool obscure = true;
await showDialog(context: context, builder: (ctx) => StatefulBuilder(
builder: (ctx, setSt) => AlertDialog(
title: Text(existing == null ? 'Nuevo Conductor' : 'Editar Conductor'),
content: SingleChildScrollView(child: Column(mainAxisSize: MainAxisSize.min, children: [
TextField(controller: nombreCtrl,
decoration: const InputDecoration(labelText: 'Nombre completo',
prefixIcon: Icon(Icons.person_outline), border: OutlineInputBorder())),
const SizedBox(height: 10),
TextField(controller: emailCtrl, keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(labelText: 'Correo electronico',
prefixIcon: Icon(Icons.email_outlined), border: OutlineInputBorder())),
const SizedBox(height: 10),
if (existing == null)
TextField(controller: passCtrl, obscureText: obscure,
decoration: InputDecoration(labelText: 'Contrasena',
prefixIcon: const Icon(Icons.lock_outline), border: const OutlineInputBorder(),
suffixIcon: IconButton(icon: Icon(obscure ? Icons.visibility_off : Icons.visibility),
onPressed: () => setSt(() => obscure = !obscure)))),
if (existing == null) const SizedBox(height: 10),
TextField(controller: notasCtrl, maxLines: 2,
decoration: const InputDecoration(labelText: 'Notas internas (opcional)',
border: OutlineInputBorder())),
const SizedBox(height: 10),
if (existing != null)
SwitchListTile(value: activo, dense: true,
title: Text(activo ? 'Conductor Activo' : 'Conductor Inactivo',
style: TextStyle(color: activo ? AppColors.verdeAdmin : AppColors.rojoError,
fontWeight: FontWeight.bold)),
activeColor: AppColors.verdeAdmin,
onChanged: (v) => setSt(() => activo = v)),
])),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancelar')),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppColors.verdeAdmin,
foregroundColor: Colors.white),
onPressed: () async {
if (nombreCtrl.text.trim().isEmpty || emailCtrl.text.trim().isEmpty) return;
if (existing == null) {
if (passCtrl.text.length < 6) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('La contrasena debe tener al menos 6 caracteres'),
backgroundColor: AppColors.rojoError));
return;
}
await DbHelper.insertConductor(nombreCtrl.text.trim(),
emailCtrl.text.trim().toLowerCase(), passCtrl.text);
} else {
await DbHelper.updateConductor(existing['id'], nombreCtrl.text.trim(),
emailCtrl.text.trim().toLowerCase());
await DbHelper.updateConductorMeta(existing['id'], activo, notasCtrl.text.trim());
}
if (ctx.mounted) Navigator.pop(ctx);
await _load();
},
child: Text(existing == null ? 'Crear' : 'Guardar')),
])));
}
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
title: Text('Conductores (${_conductores.length})'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
actions: [
IconButton(icon: const Icon(Icons.refresh), onPressed: _load),
IconButton(icon: const Icon(Icons.add_circle_outline),
tooltip: 'Nuevo conductor',
onPressed: () => _showFormDialog()),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _conductores.isEmpty
? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
const Icon(Icons.person_off, color: AppColors.grisTexto, size: 48),
const SizedBox(height: 12),
const Text('Sin conductores registrados',
style: TextStyle(color: AppColors.grisTexto)),
const SizedBox(height: 16),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(backgroundColor: AppColors.verdeAdmin,
foregroundColor: Colors.white),
onPressed: () => _showFormDialog(),
icon: const Icon(Icons.add), label: const Text('Agregar primer conductor')),
]))
: ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: _conductores.length,
itemBuilder: (_, i) {
final c = _conductores[i];
final activo = (c['activo'] as int? ?? 1) == 1;
final incidentes = c['total_incidentes'] as int? ?? 0;
return Card(
margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10),
side: BorderSide(color: activo
? AppColors.verdeAdmin.withOpacity(0.3)
: AppColors.rojoError.withOpacity(0.3))),
child: Padding(padding: const EdgeInsets.all(14), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
CircleAvatar(radius: 22,
backgroundColor: activo
? AppColors.verdeAdmin.withOpacity(0.15)
: Colors.grey.shade200,
child: Icon(Icons.person,
color: activo ? AppColors.verdeAdmin : AppColors.grisTexto, size: 24)),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(c['nombre'] ?? '', style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 14)),
Text(c['email'] ?? '', style: const TextStyle(
color: AppColors.grisTexto, fontSize: 12)),
])),
Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: activo ? AppColors.verdeAdmin.withOpacity(0.1)
: AppColors.rojoError.withOpacity(0.1),
borderRadius: BorderRadius.circular(10)),
child: Text(activo ? 'Activo' : 'Inactivo',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold,
color: activo ? AppColors.verdeAdmin : AppColors.rojoError))),
IconButton(icon: const Icon(Icons.edit_outlined, size: 18),
onPressed: () => _showFormDialog(existing: c)),
]),
if (incidentes > 0 || (c['notas'] as String?)?.isNotEmpty == true) ...[
const Divider(height: 16),
if (incidentes > 0)
Row(children: [
Icon(Icons.warning_amber, size: 14,
color: incidentes > 3 ? AppColors.rojoError : AppColors.naranjaAlerta),
const SizedBox(width: 4),
Text('$incidentes incidente${incidentes != 1 ? 's' : ''} historico${incidentes != 1 ? 's' : ''}',
style: TextStyle(fontSize: 12,
color: incidentes > 3 ? AppColors.rojoError : AppColors.naranjaAlerta)),
]),
if ((c['notas'] as String?)?.isNotEmpty == true) ...[
const SizedBox(height: 4),
Text('Notas: ${c['notas']}',
style: const TextStyle(fontSize: 11, color: AppColors.grisTexto,
fontStyle: FontStyle.italic)),
],
],
])));
}),
);
}

View File

@@ -0,0 +1,212 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/app_colors.dart';
import '../../data/celaya_colonias.dart';
import '../../data/colonies_data.dart';
import '../../database/db_helper.dart';
import '../../models/models.dart';
import '../../models/route_model.dart';
import '../../services/auth_service.dart';
class AddDomicilioScreen extends StatefulWidget {
final DomicilioModel? editing;
const AddDomicilioScreen({super.key, this.editing});
@override State<AddDomicilioScreen> createState() => _AddDomicilioScreenState();
}
class _AddDomicilioScreenState extends State<AddDomicilioScreen> {
final _calleCtrl = TextEditingController();
final _aliasCtrl = TextEditingController(text: 'Casa');
String? _coloniaSeleccionada;
ColonyModel? _coloniaData;
bool _loading = false;
String _searchQuery = '';
@override
void initState() {
super.initState();
if (widget.editing != null) {
_calleCtrl.text = widget.editing!.calle;
_aliasCtrl.text = widget.editing!.alias;
_coloniaSeleccionada = widget.editing!.colonia;
_coloniaData = getColonyByName(widget.editing!.colonia);
}
}
List<String> get _filteredColonias {
if (_searchQuery.isEmpty) return celayaColonias;
return celayaColonias
.where((c) => c.toLowerCase().contains(_searchQuery.toLowerCase()))
.toList();
}
Future<void> _guardar() async {
if (_calleCtrl.text.trim().isEmpty || _coloniaSeleccionada == null) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Por favor completa todos los campos'),
backgroundColor: AppColors.rojoError));
return;
}
setState(() => _loading = true);
final auth = context.read<AuthService>();
// 1. Buscar primero en colonies_data (rutas predefinidas)
final staticData = getColonyByName(_coloniaSeleccionada!);
String routeId = staticData?.routeId ?? '';
String horario = staticData?.horarioEstimado ?? '';
// 2. Si no hay match estático, buscar en route_definitions del admin
if (routeId.isEmpty) {
final routeDefs = await DbHelper.getAllRouteDefinitions();
for (final rd in routeDefs) {
if (rd.colonias.any((c) =>
c.toLowerCase() == _coloniaSeleccionada!.toLowerCase())) {
routeId = rd.routeId;
horario = '${_turnoLabel(rd.turno)} (${rd.horaInicio}${rd.horaFin})';
break;
}
}
}
// 3. Fallback si no se encontró
if (routeId.isEmpty) {
routeId = 'RUTA-01';
horario = 'Matutino (06:0008:00)';
}
if (widget.editing != null) {
await DbHelper.deleteDomicilio(widget.editing!.id!);
}
final dom = DomicilioModel(
userId: auth.currentUser!.id!,
alias: _aliasCtrl.text.trim(),
calle: _calleCtrl.text.trim(),
colonia: _coloniaSeleccionada!,
routeId: routeId,
horarioEstimado: horario,
);
await DbHelper.insertDomicilio(dom);
await auth.reloadDomicilios();
if (!mounted) return;
setState(() => _loading = false);
Navigator.pop(context, true);
}
String _turnoLabel(String t) =>
t == 'MATUTINO' ? 'Matutino' : t == 'VESPERTINO' ? 'Vespertino' : 'Nocturno';
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
title: Text(widget.editing != null ? 'Editar Domicilio' : 'Agregar Domicilio'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// Alias
TextField(
controller: _aliasCtrl,
decoration: const InputDecoration(
labelText: 'Alias (ej. Casa, Trabajo, Familia)',
prefixIcon: Icon(Icons.label_outline, color: AppColors.guindaPrimary),
border: OutlineInputBorder(), filled: true, fillColor: Colors.white),
),
const SizedBox(height: 12),
// Calle
TextField(
controller: _calleCtrl,
decoration: const InputDecoration(
labelText: 'Calle y número',
prefixIcon: Icon(Icons.signpost_outlined, color: AppColors.guindaPrimary),
border: OutlineInputBorder(), filled: true, fillColor: Colors.white),
),
const SizedBox(height: 16),
const Text('Colonia', style: TextStyle(fontWeight: FontWeight.bold,
color: AppColors.guindaPrimary, fontSize: 15)),
const SizedBox(height: 8),
// Buscador de colonias
TextField(
onChanged: (v) => setState(() => _searchQuery = v),
decoration: const InputDecoration(
hintText: 'Buscar colonia...',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(), filled: true, fillColor: Colors.white,
isDense: true,
),
),
const SizedBox(height: 8),
// Lista de colonias
Container(
height: 240,
decoration: BoxDecoration(
color: Colors.white, borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300)),
child: ListView.builder(
itemCount: _filteredColonias.length,
itemBuilder: (_, i) {
final c = _filteredColonias[i];
final isSelected = c == _coloniaSeleccionada;
return ListTile(
dense: true,
title: Text(c, style: TextStyle(fontSize: 13,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? AppColors.guindaPrimary : AppColors.negroTexto)),
trailing: isSelected
? const Icon(Icons.check_circle, color: AppColors.guindaPrimary, size: 18)
: null,
tileColor: isSelected ? AppColors.guindaPrimary.withOpacity(0.08) : null,
onTap: () {
setState(() {
_coloniaSeleccionada = c;
_coloniaData = getColonyByName(c);
});
},
);
},
),
),
if (_coloniaData != null) ...[
const SizedBox(height: 12),
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 asignada: ${_coloniaData!.routeId}',
style: const TextStyle(fontWeight: FontWeight.bold,
color: AppColors.guindaPrimary, fontSize: 13)),
Text('Horario: ${_coloniaData!.horarioEstimado}',
style: const TextStyle(color: AppColors.grisTexto, fontSize: 12)),
]),
),
],
const SizedBox(height: 24),
SizedBox(width: double.infinity, height: 50,
child: ElevatedButton.icon(
onPressed: _loading ? null : _guardar,
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.save),
label: Text(widget.editing != null ? 'ACTUALIZAR' : 'GUARDAR DOMICILIO',
style: const TextStyle(fontWeight: FontWeight.bold)))),
]),
),
);
}
@override void dispose() { _calleCtrl.dispose(); _aliasCtrl.dispose(); super.dispose(); }
}

View File

@@ -0,0 +1,175 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:camera/camera.dart';
import 'package:tflite_flutter/tflite_flutter.dart';
import 'package:image/image.dart' as img;
import '../../core/app_colors.dart';
List<CameraDescription> _cameras = [];
class AiCameraScreen extends StatefulWidget {
const AiCameraScreen({super.key});
@override State<AiCameraScreen> createState() => _AiCameraScreenState();
}
class _AiCameraScreenState extends State<AiCameraScreen> {
CameraController? _cam;
Interpreter? _interpreter;
bool _processing = false;
String _result = 'Apunta a un residuo y toca el botón';
String _confidence = '';
bool _modelLoaded = false;
// 0=Orgánico, 1=Inorgánico (según waste_classification_model)
final _labels = ['Residuo Organico', 'Residuo Inorganico'];
final _labelColors = [AppColors.verdeExito, AppColors.naranjaAlerta];
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
try {
_cameras = await availableCameras();
} catch (_) {}
await _initCamera();
await _loadModel();
}
Future<void> _initCamera() async {
if (_cameras.isEmpty) return;
_cam = CameraController(_cameras[0], ResolutionPreset.medium, enableAudio: false);
try {
await _cam!.initialize();
if (mounted) setState(() {});
} catch (_) {}
}
Future<void> _loadModel() async {
try {
_interpreter = await Interpreter.fromAsset('assets/models/waste_model.tflite');
setState(() => _modelLoaded = true);
} catch (e) {
setState(() => _result = 'Modelo no encontrado.\nAgrega waste_model.tflite a assets/models/');
}
}
Future<void> _classify() async {
if (_cam == null || !_cam!.value.isInitialized || _processing || !_modelLoaded) return;
setState(() { _processing = true; _result = 'Analizando...'; _confidence = ''; });
try {
final pic = await _cam!.takePicture();
final raw = await File(pic.path).readAsBytes();
img.Image? decoded = img.decodeImage(raw);
if (decoded == null) throw Exception('No se pudo decodificar');
final resized = img.copyResize(decoded, width: 150, height: 150);
var input = List.generate(1, (_) =>
List.generate(150, (_) => List.generate(150, (_) => List.generate(3, (_) => 0.0))));
for (int y = 0; y < 150; y++) {
for (int x = 0; x < 150; x++) {
final px = resized.getPixel(x, y);
input[0][y][x][0] = px.r / 255.0;
input[0][y][x][1] = px.g / 255.0;
input[0][y][x][2] = px.b / 255.0;
}
}
var output = List.filled(2, 0.0).reshape([1, 2]);
_interpreter!.run(input, output);
final pred = List<double>.from(output[0]);
final maxIdx = pred[0] > pred[1] ? 0 : 1;
final conf = pred[maxIdx] * 100;
await File(pic.path).delete();
setState(() {
_result = _labels[maxIdx];
_confidence = 'Confianza: ${conf.toStringAsFixed(1)}%';
});
} catch (e) {
setState(() => _result = 'Error en análisis');
} finally {
setState(() => _processing = false);
}
}
@override
void dispose() {
_cam?.dispose();
_interpreter?.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
final resultColor = _result.contains('Orgánico') ? AppColors.verdeExito
: _result.contains('Inorgánico') ? AppColors.naranjaAlerta
: AppColors.guindaPrimary;
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
title: const Text('Clasificador IA de Residuos'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
),
body: Column(children: [
// Visor cámara
Expanded(flex: 4,
child: Container(margin: const EdgeInsets.all(14),
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.guindaPrimary, width: 3)),
child: _cam != null && _cam!.value.isInitialized
? CameraPreview(_cam!)
: const Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.camera_alt, color: Colors.white54, size: 48),
SizedBox(height: 8),
Text('Iniciando cámara...', style: TextStyle(color: Colors.white54)),
])),
),
),
// Panel resultado
Expanded(flex: 2,
child: Container(width: double.infinity,
decoration: BoxDecoration(color: AppColors.guindaPrimary.withOpacity(0.06),
borderRadius: const BorderRadius.vertical(top: Radius.circular(28))),
padding: const EdgeInsets.all(20),
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Text(_result, textAlign: TextAlign.center,
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: resultColor)),
if (_confidence.isNotEmpty) ...[
const SizedBox(height: 6),
Text(_confidence, style: const TextStyle(fontSize: 16, color: Colors.black54, fontWeight: FontWeight.w500)),
],
const SizedBox(height: 16),
if (!_modelLoaded)
Container(padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: Colors.orange.shade50, borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.shade300)),
child: const Text(' Para usar la IA, coloca waste_model.tflite en assets/models/',
textAlign: TextAlign.center, style: TextStyle(fontSize: 11))),
if (_modelLoaded)
SizedBox(width: double.infinity, height: 50,
child: ElevatedButton.icon(
onPressed: _processing ? null : _classify,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14))),
icon: _processing
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Icon(Icons.center_focus_strong),
label: Text(_processing ? 'Procesando...' : 'Escanear Residuo',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
)),
]),
),
),
]),
);
}
}

View File

@@ -0,0 +1,270 @@
import 'package:flutter/material.dart';
import '../../core/app_colors.dart';
// ── Árbol de respuestas predefinidas ──────────────────────────────────────
class _ChatNode {
final String text;
final List<_ChatOption> options;
final bool isAnswer;
const _ChatNode(this.text, this.options, {this.isAnswer = false});
}
class _ChatOption {
final String label;
final _ChatNode next;
const _ChatOption(this.label, this.next);
}
final _chatTree = _ChatNode('Hola, soy el asistente de Celaya Limpia. ¿En que te puedo ayudar?', [
_ChatOption('Separacion de residuos', _ChatNode('¿Que quieres saber sobre separacion?', [
_ChatOption('Como separo mi basura', _ChatNode(
'Separa tus residuos en 3 grupos:\n\n'
'ORGANICOS (bolsa verde):\nRestos de comida, cascara de huevo, pasto, hojas.\n\n'
'INORGANICOS reciclables (bolsa azul):\nPET, latas, carton limpio, vidrio.\n\n'
'NO reciclables (bolsa negra):\nPanales, papel sanitario, colillas, chicles.',
[], isAnswer: true)),
_ChatOption('Que NO debo mezclar', _ChatNode(
'NUNCA mezcles:\n\n'
'- Pilas o baterias con basura comun\n'
'- Aceite de cocina (contamina el agua)\n'
'- Medicamentos vencidos\n'
'- Jeringas o material medico\n'
'- Electronicos con basura doméstica\n\n'
'Estos requieren manejo especial.',
[], isAnswer: true)),
_ChatOption('Que hago con el aceite', _ChatNode(
'El aceite de cocina usado NO va a la basura ni al drenaje.\n\n'
'1. Dejalo enfriar completamente\n'
'2. Guardalo en botella de PET cerrada\n'
'3. Llevalo a los puntos de acopio del Ayuntamiento de Celaya\n\n'
'El aceite reciclado se convierte en biodiesel.',
[], isAnswer: true)),
])),
_ChatOption('Residuos especiales', _ChatNode('¿Que tipo de residuo especial tienes?', [
_ChatOption('Donde dejo electronicos', _ChatNode(
'Los aparatos electronicos (celulares, computadoras, focos ahorradores) '
'son residuos RAEE.\n\n'
'Puntos de acopio en Celaya:\n'
'- Tiendas de electronica\n'
'- Centros comerciales con contenedores especiales\n'
'- Eventos de recoleccion del municipio\n\n'
'NUNCA los tires a la basura comun.',
[], isAnswer: true)),
_ChatOption('Que hago con medicamentos', _ChatNode(
'Los medicamentos vencidos son residuos peligrosos.\n\n'
'- Llevalos a farmacias que tengan programa de devolucion\n'
'- Algunos hospitales los reciben\n'
'- Nunca los tires al drenaje ni a la basura comun\n\n'
'Contaminar el agua con medicamentos afecta a toda la comunidad.',
[], isAnswer: true)),
_ChatOption('Que hago con pilas y baterias', _ChatNode(
'Las pilas y baterias contienen metales pesados toxicos.\n\n'
'Depositalas en:\n'
'- Supermercados (contenedores naranjas)\n'
'- Tiendas de electronica\n'
'- Oficinas del Ayuntamiento de Celaya\n\n'
'1 pila puede contaminar 600,000 litros de agua.',
[], isAnswer: true)),
])),
_ChatOption('Sobre el servicio de recoleccion', _ChatNode('¿Que necesitas saber?', [
_ChatOption('Cuando debo sacar la basura', _ChatNode(
'Celaya Limpia te notificara:\n\n'
'1. Cuando el camion salga del relleno sanitario\n'
'2. Cuando este a 30 minutos\n'
'3. A 15 minutos: este es el momento de sacar tus bolsas\n\n'
'NO saques la basura antes del aviso de 15 minutos. '
'Atrae fauna nociva y obstruye la acera.',
[], isAnswer: true)),
_ChatOption('El camion no paso', _ChatNode(
'Si el camion no paso en tu horario habitual:\n\n'
'1. Revisa las alertas en la app (puede haber un retraso o incidente)\n'
'2. Guarda tu basura bien cerrada\n'
'3. Reporta la incidencia desde la seccion "Reportar"\n\n'
'El administrador revisara tu reporte y te mantendra informado.',
[], isAnswer: true)),
_ChatOption('Como califico el servicio', _ChatNode(
'Despues de que el camion pase por tu zona, '
'la app te mostrara una notificacion para calificar.\n\n'
'Puedes dar de 1 a 5 estrellas y dejar un comentario.\n\n'
'Tus calificaciones ayudan al Ayuntamiento a identificar '
'colonias con problemas y mejorar el servicio.',
[], isAnswer: true)),
])),
_ChatOption('Denuncia o emergencia', _ChatNode(
'Para situaciones urgentes:\n\n'
'- Reporte de incidencias: usa la seccion "Reportar" en la app\n'
'- Emergencias: llama al 911\n'
'- Ayuntamiento de Celaya: (461) 614-8000\n'
'- SEMARNAT Guanajuato: (477) 717-2600\n\n'
'Para basura clandestina o tiraderos ilegales, reportalo al municipio.',
[], isAnswer: true)),
]);
// ── Pantalla del chatbot ──────────────────────────────────────────────────
class ChatbotScreen extends StatefulWidget {
const ChatbotScreen({super.key});
@override State<ChatbotScreen> createState() => _ChatbotScreenState();
}
class _ChatbotScreenState extends State<ChatbotScreen> {
final List<_Message> _messages = [];
_ChatNode _current = _chatTree;
final _scroll = ScrollController();
@override
void initState() {
super.initState();
// Mensaje inicial
_messages.add(_Message(text: _chatTree.text, isBot: true));
}
void _handleOption(_ChatOption option) {
setState(() {
// Mensaje del usuario
_messages.add(_Message(text: option.label, isBot: false));
// Ir al siguiente nodo
_current = option.next;
_messages.add(_Message(text: _current.text, isBot: true,
isAnswer: _current.isAnswer));
});
Future.delayed(const Duration(milliseconds: 100), () {
_scroll.animateTo(_scroll.position.maxScrollExtent,
duration: const Duration(milliseconds: 300), curve: Curves.easeOut);
});
}
void _reset() {
setState(() {
_messages.clear();
_current = _chatTree;
_messages.add(_Message(text: _chatTree.text, isBot: true));
});
}
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
title: const Row(children: [
CircleAvatar(radius: 14,
backgroundColor: Colors.white24,
child: Icon(Icons.smart_toy, color: AppColors.dorado, size: 18)),
SizedBox(width: 8),
Text('Asistente Celaya Limpia'),
]),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
actions: [
IconButton(icon: const Icon(Icons.refresh), tooltip: 'Reiniciar',
onPressed: _reset),
],
),
body: Column(children: [
// Mensajes
Expanded(
child: ListView.builder(
controller: _scroll,
padding: const EdgeInsets.all(12),
itemCount: _messages.length,
itemBuilder: (_, i) => _MessageBubble(msg: _messages[i]),
),
),
// Opciones del nodo actual
if (_current.options.isNotEmpty)
Container(
color: Colors.white,
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Selecciona una opcion:',
style: TextStyle(fontSize: 11, color: AppColors.grisTexto,
fontWeight: FontWeight.w500)),
const SizedBox(height: 8),
Wrap(spacing: 8, runSpacing: 8,
children: _current.options.map((opt) =>
ActionChip(
label: Text(opt.label, style: const TextStyle(fontSize: 12)),
backgroundColor: AppColors.guindaPrimary.withOpacity(0.1),
side: const BorderSide(color: AppColors.guindaPrimary),
labelStyle: const TextStyle(color: AppColors.guindaPrimary),
onPressed: () => _handleOption(opt),
)).toList()),
],
),
)
else
// Botón de reiniciar al llegar a una respuesta final
Container(
color: Colors.white,
padding: const EdgeInsets.all(12),
child: SizedBox(width: double.infinity,
child: OutlinedButton.icon(
onPressed: _reset,
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.guindaPrimary,
side: const BorderSide(color: AppColors.guindaPrimary)),
icon: const Icon(Icons.arrow_back, size: 16),
label: const Text('Hacer otra pregunta'))),
),
]),
);
@override void dispose() { _scroll.dispose(); super.dispose(); }
}
class _Message {
final String text;
final bool isBot;
final bool isAnswer;
const _Message({required this.text, required this.isBot, this.isAnswer = false});
}
class _MessageBubble extends StatelessWidget {
final _Message msg;
const _MessageBubble({super.key, required this.msg});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: msg.isBot ? MainAxisAlignment.start : MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (msg.isBot) ...[
CircleAvatar(radius: 16,
backgroundColor: AppColors.guindaPrimary,
child: const Icon(Icons.smart_toy, color: Colors.white, size: 16)),
const SizedBox(width: 8),
],
Flexible(child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: msg.isBot
? (msg.isAnswer ? Colors.green.shade50 : Colors.white)
: AppColors.guindaPrimary,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: Radius.circular(msg.isBot ? 4 : 16),
bottomRight: Radius.circular(msg.isBot ? 16 : 4),
),
border: msg.isBot ? Border.all(
color: msg.isAnswer
? Colors.green.shade200
: Colors.grey.shade200) : null,
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.06),
blurRadius: 4, offset: const Offset(0, 2))],
),
child: Text(msg.text,
style: TextStyle(fontSize: 13, height: 1.5,
color: msg.isBot ? AppColors.negroTexto : Colors.white)),
)),
if (!msg.isBot) const SizedBox(width: 8),
],
),
);
}
}

View File

@@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
import '../../core/app_colors.dart';
import 'ai_camera_screen.dart';
class CitizenGuiaScreen extends StatelessWidget {
const CitizenGuiaScreen({super.key});
static const _cats = [
_Cat(Icons.grass,Color(0xFF2E7D32),'Orgánicos','Restos de comida, jardín','🟢 Bolsa Verde',[
'Frutas y verduras','Cáscaras de huevo','Posos de café y té',
'Restos de comida preparada','Pasto y hojas','Cáscaras de semillas'],
['Aceites en exceso','Carnes en grandes cantidades']),
_Cat(Icons.recycling,Color(0xFF1565C0),'Reciclables','Papel, plástico, vidrio, metal','🔵 Bolsa Azul',[
'Botellas PET','Latas de aluminio','Cartón y papel limpio',
'Vidrio (botellas, frascos)','Periódico y revistas'],
['Vidrio roto sin envolver','Papel sucio o mojado','Unicel']),
_Cat(Icons.delete,Color(0xFF757575),'No Reciclables','Residuos que no se reusan','⚫ Bolsa Negra',[
'Pañales desechables','Toallas sanitarias','Papel higiénico usado',
'Colillas de cigarro','Cerámica rota'],['Baterías','Medicamentos','Aceite usado']),
_Cat(Icons.warning_amber,Color(0xFFC62828),'Peligrosos','Requieren manejo especial','🔴 Separado',[
'Agujas y jeringas','Medicamentos vencidos','Pilas y baterías',
'Aceite de cocina usado','Pintura y solventes'],[],isWarn:true),
_Cat(Icons.devices_other,Color(0xFFE65100),'Electrónicos (RAEE)','Aparatos electrónicos','🟠 Punto de acopio',[
'Celulares viejos','Computadoras','Televisiones',
'Focos ahorradores','Cables y cargadores'],[],isSpecial:true),
];
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(automaticallyImplyLeading:false,
backgroundColor:AppColors.guindaPrimary, foregroundColor:Colors.white,
title:const Text('Guía de Separación'),
actions:[IconButton(icon:const Icon(Icons.camera_alt),
tooltip:'Clasificar con IA',
onPressed:()=>Navigator.push(context,MaterialPageRoute(builder:(_)=>const AiCameraScreen())))],
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
child:Container(height:4,color:AppColors.dorado))),
body:Column(children:[
Container(width:double.infinity,
color:AppColors.verdeExito.withOpacity(0.1),
padding:const EdgeInsets.symmetric(horizontal:16,vertical:8),
child:Row(children:[
const Icon(Icons.offline_bolt,color:AppColors.verdeExito,size:16),
const SizedBox(width:6),
const Text('Disponible sin conexión a internet',
style:TextStyle(color:AppColors.verdeExito,fontSize:12,fontWeight:FontWeight.w500)),
const Spacer(),
TextButton.icon(icon:const Icon(Icons.camera_alt,size:14),
label:const Text('Clasificar IA',style:TextStyle(fontSize:12)),
style:TextButton.styleFrom(foregroundColor:AppColors.guindaPrimary),
onPressed:()=>Navigator.push(context,MaterialPageRoute(builder:(_)=>const AiCameraScreen()))),
])),
// Importancia de separar
Container(margin:const EdgeInsets.fromLTRB(12,8,12,0),
padding:const EdgeInsets.all(12),
decoration:BoxDecoration(color:Colors.green.shade50,borderRadius:BorderRadius.circular(8),
border:Border.all(color:Colors.green.shade200)),
child:const Column(crossAxisAlignment:CrossAxisAlignment.start, children:[
Text('¿Por qué separar tu basura?',style:TextStyle(fontWeight:FontWeight.bold,color:Color(0xFF2E7D32))),
SizedBox(height:6),
Text('♻️ El 60% de los residuos en México pueden reciclarse o compostarse, pero solo el 5% lo hace.\n'
'🌱 Separar correctamente reduce la contaminación del suelo y agua, genera empleos verdes '
'y disminuye los gases de efecto invernadero producidos en rellenos sanitarios.',
style:TextStyle(fontSize:12,color:Colors.black87)),
])),
Expanded(child:ListView.builder(
padding:const EdgeInsets.all(12),
itemCount:_cats.length,
itemBuilder:(ctx,i)=>_CatCard(cat:_cats[i]))),
]),
);
}
class _Cat {
final IconData icon; final Color color; final String title, subtitle, bolsa;
final List<String> items, noItems;
final bool isWarn, isSpecial;
const _Cat(this.icon,this.color,this.title,this.subtitle,this.bolsa,
this.items,this.noItems,{this.isWarn=false,this.isSpecial=false});
}
class _CatCard extends StatefulWidget {
final _Cat cat;
const _CatCard({super.key, required this.cat});
@override State<_CatCard> createState() => _CatCardState();
}
class _CatCardState extends State<_CatCard> {
bool _open = false;
@override
Widget build(BuildContext context) {
final c = widget.cat;
return Card(margin:const EdgeInsets.only(bottom:10),elevation:2,
shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(10),
side:BorderSide(color:c.color.withOpacity(0.3))),
child:InkWell(borderRadius:BorderRadius.circular(10),
onTap:()=>setState(()=>_open=!_open),
child:Column(children:[
Container(decoration:BoxDecoration(color:c.color.withOpacity(0.1),
borderRadius:BorderRadius.vertical(top:const Radius.circular(10),
bottom:_open?Radius.zero:const Radius.circular(10))),
padding:const EdgeInsets.all(14),
child:Row(children:[
Container(width:40,height:40,decoration:BoxDecoration(color:c.color,borderRadius:BorderRadius.circular(8)),
child:Icon(c.icon,color:Colors.white,size:22)),
const SizedBox(width:10),
Expanded(child:Column(crossAxisAlignment:CrossAxisAlignment.start, children:[
Text(c.title,style:TextStyle(fontWeight:FontWeight.bold,fontSize:15,color:c.color)),
Text(c.subtitle,style:const TextStyle(color:AppColors.grisTexto,fontSize:11)),
Text(c.bolsa,style:TextStyle(fontSize:11,fontWeight:FontWeight.w600,color:c.color)),
])),
Icon(_open?Icons.expand_less:Icons.expand_more,color:c.color),
])),
if (_open) Padding(padding:const EdgeInsets.fromLTRB(14,0,14,14),
child:Column(crossAxisAlignment:CrossAxisAlignment.start, children:[
const SizedBox(height:8),
Text('✅ Qué va aquí:',style:TextStyle(fontWeight:FontWeight.bold,color:c.color,fontSize:12)),
const SizedBox(height:4),
...c.items.map((e)=>Padding(padding:const EdgeInsets.symmetric(vertical:2),
child:Row(children:[Icon(Icons.check_circle_outline,size:13,color:c.color),
const SizedBox(width:6),Text(e,style:const TextStyle(fontSize:12))]))),
if (c.noItems.isNotEmpty) ...[
const SizedBox(height:8),
const Text('❌ NO incluir:',style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.rojoError,fontSize:12)),
...c.noItems.map((e)=>Padding(padding:const EdgeInsets.symmetric(vertical:2),
child:Row(children:[const Icon(Icons.cancel_outlined,size:13,color:AppColors.rojoError),
const SizedBox(width:6),Text(e,style:const TextStyle(fontSize:12,color:AppColors.rojoError))]))),
],
if (c.isSpecial) ...[
const SizedBox(height:8),
Container(padding:const EdgeInsets.all(8),
decoration:BoxDecoration(color:Colors.orange.shade50,borderRadius:BorderRadius.circular(6),
border:Border.all(color:Colors.orange.shade200)),
child:const Text('📍 Lleva a puntos de acopio autorizados por el municipio.',
style:TextStyle(fontSize:11))),
],
if (c.isWarn) ...[
const SizedBox(height:8),
Container(padding:const EdgeInsets.all(8),
decoration:BoxDecoration(color:Colors.red.shade50,borderRadius:BorderRadius.circular(6),
border:Border.all(color:Colors.red.shade200)),
child:const Text('⚠️ NUNCA mezcles residuos peligrosos con basura común.',
style:TextStyle(fontSize:11))),
],
])),
])));
}
}

View File

@@ -0,0 +1,582 @@
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';
import '../../services/route_simulator_service.dart';
import '../../data/routes_data.dart';
import '../../widgets/route_map_widget.dart';
import 'citizen_guia_screen.dart';
import 'citizen_reporte_screen.dart';
import 'add_domicilio_screen.dart';
import 'review_screen.dart';
import 'collection_calendar_screen.dart';
import 'notification_history_screen.dart';
import 'chatbot_screen.dart';
import '../settings_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;
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'),
],
),
);
}
}
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;
RouteDefinitionModel? _routeDef;
@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);
final rd = await DbHelper.getRouteDefinitionById(dom.routeId);
if (mounted) setState(() { _routeStatus = s; _routeDef = rd; });
}
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 allDoms = widget.auth.allDomicilios;
final routeId = dom?.routeId ?? '';
final route = dom != null ? getRouteById(dom.routeId) : null;
final isTruckClose = widget.sim.isTruckClose(routeId);
final isCompleted = widget.sim.isRouteCompleted(routeId);
final needsReview = widget.sim.needsReviewPrompt(routeId);
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([
// ── Selector de domicilio ────────────────────────────────────
if (allDoms.length > 1) _DomicilioSelector(
auth: widget.auth, onChanged: _loadStatus),
// ── Prompt de calificación ───────────────────────────────────
if (needsReview && dom != null)
_ReviewPromptCard(routeId: routeId, colonia: dom.colonia,
sim: widget.sim),
// ── Estado de ruta (cancelada/falla/retrasada) ───────────────
if (_isRouteProblematic)
_RouteStatusBanner(status: _routeStatus!)
else ...[
// ETA Card
_EtaCard(sim: widget.sim, routeId: routeId, dom: dom, route: route),
const SizedBox(height: 12),
// Información detallada de la ruta (días y horario)
if (_routeDef != null) _RouteInfoCard(routeDef: _routeDef!),
if (_routeDef == null && dom != null) _BasicRouteInfo(dom: dom),
const SizedBox(height: 12),
// Mapa solo cuando camión está cerca (<15 min)
if (isTruckClose && route != null && !isCompleted) ...[
_WarningNoPursue(),
const SizedBox(height: 8),
RouteMapWidget(route: route, simulator: widget.sim, height: 220),
const SizedBox(height: 12),
],
],
// Aviso privacidad
_PrivacyBanner(),
const SizedBox(height: 12),
// Mis domicilios
_DomiciliosCard(auth: widget.auth),
const SizedBox(height: 12),
// Historial notificaciones
if (widget.sim.historyForRoute(routeId).isNotEmpty)
_HistorialCard(sim: widget.sim, routeId: routeId),
const SizedBox(height: 80),
])),
),
]),
);
}
}
// ── Selector de domicilio activo ──────────────────────────────────────────
class _DomicilioSelector extends StatelessWidget {
final AuthService auth; final VoidCallback onChanged;
const _DomicilioSelector({required this.auth, required this.onChanged});
@override
Widget build(BuildContext context) {
return Container(margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.guindaPrimary.withOpacity(0.3)),
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 4)]),
child: DropdownButtonHideUnderline(
child: DropdownButton<int>(
isExpanded: true,
value: auth.primaryDomicilio?.id,
icon: const Icon(Icons.swap_horiz, color: AppColors.guindaPrimary),
items: auth.allDomicilios.map((d) => DropdownMenuItem(
value: d.id,
child: Row(children: [
Icon(d.isPrimary ? Icons.home : Icons.location_on_outlined,
color: AppColors.guindaPrimary, size: 16),
const SizedBox(width: 6),
Expanded(child: Text('${d.alias}${d.colonia}',
style: const TextStyle(fontSize: 13), overflow: TextOverflow.ellipsis)),
]))).toList(),
onChanged: (id) async {
if (id != null) {
await DbHelper.setPrimaryDomicilio(id, auth.currentUser!.id!);
await auth.reloadDomicilios();
onChanged();
}
},
),
));
}
}
// ── Prompt de reseña ──────────────────────────────────────────────────────
class _ReviewPromptCard extends StatelessWidget {
final String routeId, colonia; final RouteSimulatorService sim;
const _ReviewPromptCard({required this.routeId, required this.colonia, required this.sim});
@override
Widget build(BuildContext context) => Card(
color: Colors.amber.shade50,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.amber.shade300, width: 1.5)),
child: Padding(padding: const EdgeInsets.all(14), child: Column(children: [
const Row(children: [
Text('', style: TextStyle(fontSize: 24)),
SizedBox(width: 8),
Expanded(child: Text('¿Cómo estuvo el servicio de hoy?',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14))),
]),
const SizedBox(height: 4),
const Text('El camión pasó por tu colonia. Toma un momento para calificar el servicio.',
style: TextStyle(fontSize: 12, color: AppColors.grisTexto)),
const SizedBox(height: 10),
Row(children: [
Expanded(child: ElevatedButton.icon(
onPressed: () => Navigator.push(context, MaterialPageRoute(
builder: (_) => ReviewScreen(routeId: routeId, colonia: colonia))),
style: ElevatedButton.styleFrom(backgroundColor: Colors.amber,
foregroundColor: Colors.black87),
icon: const Icon(Icons.star, size: 16),
label: const Text('Calificar', style: TextStyle(fontWeight: FontWeight.bold)))),
const SizedBox(width: 8),
TextButton(onPressed: () => sim.clearReviewPrompt(routeId),
child: const Text('Después', style: TextStyle(color: AppColors.grisTexto))),
]),
])));
}
// ── Info detallada de la ruta ─────────────────────────────────────────────
class _RouteInfoCard extends StatelessWidget {
final RouteDefinitionModel routeDef;
const _RouteInfoCard({required this.routeDef});
@override
Widget build(BuildContext context) => Card(
child: Padding(padding: const EdgeInsets.all(14), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
const Row(children: [
Icon(Icons.schedule, color: AppColors.guindaPrimary, size: 16),
SizedBox(width: 6),
Text('Información de tu ruta', style: TextStyle(
fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)),
]),
const Divider(),
Text(routeDef.nombre, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
const SizedBox(height: 4),
Row(children: [
const Icon(Icons.access_time, size: 13, color: AppColors.grisTexto),
const SizedBox(width: 4),
Text('${routeDef.horaInicio}${routeDef.horaFin} (${_turnoLabel(routeDef.turno)})',
style: const TextStyle(fontSize: 12, color: AppColors.negroTexto)),
]),
const SizedBox(height: 4),
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Icon(Icons.calendar_today, size: 13, color: AppColors.grisTexto),
const SizedBox(width: 4),
Expanded(child: Text(
routeDef.dias.map(AppDias.label).join(', '),
style: const TextStyle(fontSize: 12, color: AppColors.negroTexto))),
]),
])));
String _turnoLabel(String t) => t=='MATUTINO'?'🌄 Matutino':t=='VESPERTINO'?'🌅 Vespertino':'🌙 Nocturno';
}
class _BasicRouteInfo extends StatelessWidget {
final DomicilioModel dom;
const _BasicRouteInfo({required this.dom});
@override
Widget build(BuildContext context) => Card(
child: Padding(padding: const EdgeInsets.all(14), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
const Row(children: [
Icon(Icons.schedule, color: AppColors.guindaPrimary, size: 16),
SizedBox(width: 6),
Text('Tu servicio de recolección', style: TextStyle(
fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)),
]),
const Divider(),
Text('Ruta: ${dom.routeId}', style: const TextStyle(fontWeight: FontWeight.w600)),
Text('Horario: ${dom.horarioEstimado}',
style: const TextStyle(color: AppColors.grisTexto, fontSize: 12)),
])));
}
// ── Aviso anti-persecución ────────────────────────────────────────────────
class _WarningNoPursue extends StatelessWidget {
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: Colors.red.shade50, borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.shade300)),
child: const Row(children: [
Icon(Icons.warning_amber_rounded, color: AppColors.rojoError, size: 20),
SizedBox(width: 8),
Expanded(child: Text(
'⚠️ Ya es momento de sacar tu basura.\n'
'🚫 NO persigas ni interceptes el camión en movimiento.\n'
'✅ Coloca tus bolsas en la acera y espera.',
style: TextStyle(fontSize: 11, color: AppColors.rojoError, fontWeight: FontWeight.w500))),
]));
}
// ── Mis domicilios ────────────────────────────────────────────────────────
class _DomiciliosCard extends StatelessWidget {
final AuthService auth;
const _DomiciliosCard({required this.auth});
@override
Widget build(BuildContext context) {
final doms = auth.allDomicilios;
return Card(child: Padding(padding: const EdgeInsets.all(14), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
const Icon(Icons.home_outlined, color: AppColors.guindaPrimary, size: 16),
const SizedBox(width: 6),
const Expanded(child: Text('Mis Domicilios',
style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.guindaPrimary))),
TextButton.icon(
onPressed: () async {
final result = await Navigator.push(context,
MaterialPageRoute(builder: (_) => const AddDomicilioScreen()));
if (result == true) await auth.reloadDomicilios();
},
icon: const Icon(Icons.add, size: 14),
label: const Text('Agregar', style: TextStyle(fontSize: 12)),
style: TextButton.styleFrom(foregroundColor: AppColors.guindaPrimary)),
]),
const Divider(),
if (doms.isEmpty)
const Text('Sin domicilios registrados',
style: TextStyle(color: AppColors.grisTexto, fontSize: 12))
else
...doms.map((d) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(children: [
Icon(d.isPrimary ? Icons.home : Icons.location_on_outlined,
color: d.isPrimary ? AppColors.guindaPrimary : AppColors.grisTexto, size: 16),
const SizedBox(width: 8),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('${d.alias}${d.colonia}',
style: TextStyle(fontWeight: d.isPrimary ? FontWeight.bold : FontWeight.normal,
fontSize: 12)),
Text(d.calle, style: const TextStyle(color: AppColors.grisTexto, fontSize: 11)),
Text('${d.routeId}${d.horarioEstimado}',
style: const TextStyle(color: AppColors.grisTexto, fontSize: 10)),
])),
if (!d.isPrimary)
IconButton(icon: const Icon(Icons.star_border, size: 16, color: AppColors.dorado),
tooltip: 'Hacer principal',
onPressed: () async {
await DbHelper.setPrimaryDomicilio(d.id!, auth.currentUser!.id!);
await auth.reloadDomicilios();
}),
IconButton(icon: const Icon(Icons.edit_outlined, size: 14, color: AppColors.grisTexto),
onPressed: () async {
final result = await Navigator.push(context, MaterialPageRoute(
builder: (_) => AddDomicilioScreen(editing: d)));
if (result == true) await auth.reloadDomicilios();
}),
if (!d.isPrimary)
IconButton(icon: const Icon(Icons.delete_outline, size: 14, color: AppColors.rojoError),
onPressed: () async {
await DbHelper.deleteDomicilio(d.id!);
await auth.reloadDomicilios();
}),
]))),
])));
}
}
// ── 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';
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Container(width: double.infinity, padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(12)),
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: 8),
Text(isCancelled
? 'El servicio no se realizará hoy. Guarda tus residuos para mañana.'
: isFalla
? 'El camión presentó una falla. El Ayuntamiento atiende la situación.'
: 'El camión presenta un retraso. El servicio se realizará con demora.',
style: const TextStyle(color: Colors.white, fontSize: 13)),
])),
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)),
])),
],
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),
Text(isCancelled
? '• Guarda tus bolsas en lugar cerrado\n• No dejes residuos en la acera\n• Revisa la app mañana'
: isRetrasada
? '• Espera el aviso de 15 minutos antes de sacar tu basura\n• El camión llegará eventualmente\n• Recibe la notificación en esta app'
: '• Espera confirmación del Ayuntamiento\n• Puede enviarse unidad de reemplazo',
style: const TextStyle(fontSize: 12, color: AppColors.grisTexto)),
])),
const SizedBox(height: 12),
]);
}
}
// ── 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??dom?.routeId??'Tu ruta',
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:6),
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)),
]));
}
// ── Privacidad ────────────────────────────────────────────────────────────
class _PrivacyBanner extends StatelessWidget {
@override
Widget build(BuildContext context) => 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))),
]));
}
// ── Historial notificaciones ──────────────────────────────────────────────
class _HistorialCard extends StatelessWidget {
final RouteSimulatorService sim; final String routeId;
const _HistorialCard({required this.sim, required this.routeId});
@override
Widget build(BuildContext context) {
final notifs = sim.historyForRoute(routeId).take(5).toList();
return 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(),
...notifs.map((n){
final color = n.event==NotifEvent.truckProximity||n.event==NotifEvent.truckApproaching15min
?AppColors.naranjaAlerta:n.event==NotifEvent.routeCompleted||n.event==NotifEvent.reviewPrompt
?AppColors.verdeExito:n.event==NotifEvent.routeCancelled?AppColors.rojoError:AppColors.azulInfo;
return Padding(padding:const EdgeInsets.symmetric(vertical:3),
child:Row(children:[
Icon(Icons.circle,size:8,color:color),
const SizedBox(width:8),
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)),
]));
}),
])));
}
}
// ── Notif Banner ──────────────────────────────────────────────────────────
class _NotifBanner extends StatelessWidget {
final AppNotification notif; final VoidCallback onDismiss;
const _NotifBanner({required this.notif, required this.onDismiss});
@override
Widget build(BuildContext context) {
final isUrgent = notif.event==NotifEvent.truckProximity||notif.event==NotifEvent.truckApproaching15min;
final isReview = notif.event==NotifEvent.reviewPrompt;
final color = isUrgent?AppColors.naranjaAlerta
:isReview?Colors.amber.shade700
: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:[
Icon(isReview?Icons.star:Icons.notifications_active,color:Colors.white,size:24),
const SizedBox(width:10),
Expanded(child:Column(crossAxisAlignment:CrossAxisAlignment.start,
mainAxisSize:MainAxisSize.min,children:[
Text(notif.title,style:const TextStyle(color:Colors.white,fontWeight:FontWeight.bold,fontSize:13)),
Text(notif.body,style:const TextStyle(color:Colors.white70,fontSize:11),
maxLines:2,overflow:TextOverflow.ellipsis),
])),
IconButton(icon:const Icon(Icons.close,color:Colors.white,size:18),onPressed:onDismiss),
]))));
}
}

View File

@@ -0,0 +1,221 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:image_picker/image_picker.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 = [];
File? _foto;
final _picker = ImagePicker();
static const _tipos = {
'CAMION_NO_PASO': 'El camion no paso',
'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> _pickImage(ImageSource source) async {
try {
final picked = await _picker.pickImage(source: source, imageQuality: 70, maxWidth: 1024);
if (picked != null && mounted) setState(() => _foto = File(picked.path));
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('No se pudo acceder a la camara: $e'),
backgroundColor: AppColors.rojoError));
}
}
void _showPhotoOptions() {
showModalBottomSheet(context: context, builder: (_) => SafeArea(
child: Column(mainAxisSize: MainAxisSize.min, children: [
ListTile(leading: const Icon(Icons.camera_alt, color: AppColors.guindaPrimary),
title: const Text('Tomar foto'),
onTap: () { Navigator.pop(context); _pickImage(ImageSource.camera); }),
ListTile(leading: const Icon(Icons.photo_library, color: AppColors.guindaPrimary),
title: const Text('Elegir de galeria'),
onTap: () { Navigator.pop(context); _pickImage(ImageSource.gallery); }),
if (_foto != null)
ListTile(leading: const Icon(Icons.delete_outline, color: AppColors.rojoError),
title: const Text('Quitar foto', style: TextStyle(color: AppColors.rojoError)),
onTap: () { Navigator.pop(context); setState(() => _foto = null); }),
])));
}
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);
final db = await DbHelper.database;
await db.insert('reportes', {
'user_id': auth.currentUser!.id,
'tipo': _tipo,
'descripcion': _desc.text.trim(),
'colonia': auth.primaryDomicilio?.colonia ?? '',
'route_id': auth.primaryDomicilio?.routeId ?? '',
'fecha': DateTime.now().toIso8601String(),
'estado': 'PENDIENTE',
'calificacion': _calif,
'foto_path': _foto?.path,
});
await _load();
if (!mounted) return;
setState(() { _loading = false; _sent = true; _desc.clear(); _foto = null; });
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 revisara 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('Tipo de incidencia', style: TextStyle(
fontWeight: FontWeight.bold, color: AppColors.guindaPrimary, 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.guindaPrimary,
onChanged: (v) => setState(() => _tipo = v!))),
const SizedBox(height: 8),
DropdownButtonFormField<int>(value: _calif,
decoration: const InputDecoration(labelText: 'Calificacion del servicio',
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: 12),
// Foto adjunta
const Text('Foto del incidente (opcional)',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
const SizedBox(height: 8),
GestureDetector(
onTap: _showPhotoOptions,
child: Container(
width: double.infinity, height: _foto != null ? 180 : 80,
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: _foto != null
? AppColors.guindaPrimary : Colors.grey.shade300,
style: BorderStyle.solid)),
child: _foto != null
? Stack(children: [
ClipRRect(borderRadius: BorderRadius.circular(8),
child: Image.file(_foto!, fit: BoxFit.cover,
width: double.infinity, height: 180)),
Positioned(top: 8, right: 8,
child: GestureDetector(onTap: () => setState(() => _foto = null),
child: Container(padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: AppColors.rojoError, shape: BoxShape.circle),
child: const Icon(Icons.close, color: Colors.white, size: 16)))),
])
: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
const Icon(Icons.add_a_photo_outlined,
color: AppColors.grisTexto, size: 28),
const SizedBox(height: 4),
const Text('Agregar foto', style: TextStyle(
color: AppColors.grisTexto, fontSize: 12)),
]),
),
),
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: CircleAvatar(backgroundColor: AppColors.guindaPrimary, radius: 16,
child: const 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: _estadoColor(r.estado).withOpacity(0.15),
borderRadius: BorderRadius.circular(10)),
child: Text(r.estado, style: TextStyle(fontSize: 9,
color: _estadoColor(r.estado), fontWeight: FontWeight.bold)))))),
],
])),
);
Color _estadoColor(String e) {
switch (e) {
case 'RESUELTO': return AppColors.verdeExito;
case 'EN_REVISION': return AppColors.azulInfo;
default: return AppColors.naranjaAlerta;
}
}
@override void dispose() { _desc.dispose(); super.dispose(); }
}

View File

@@ -0,0 +1,253 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import '../../core/app_colors.dart';
import '../../database/db_helper.dart';
import '../../models/models.dart';
import '../../services/auth_service.dart';
class CollectionCalendarScreen extends StatefulWidget {
const CollectionCalendarScreen({super.key});
@override State<CollectionCalendarScreen> createState() => _CollectionCalendarScreenState();
}
class _CollectionCalendarScreenState extends State<CollectionCalendarScreen> {
RouteDefinitionModel? _routeDef;
List<ReviewModel> _myReviews = [];
bool _loading = true;
@override
void initState() { super.initState(); _load(); }
Future<void> _load() async {
final auth = context.read<AuthService>();
final dom = auth.primaryDomicilio;
if (dom != null) {
final rd = await DbHelper.getRouteDefinitionById(dom.routeId);
final rv = await DbHelper.getAllReviews();
final mine = rv.where((r) => r.userId == auth.currentUser?.id).toList();
if (mounted) setState(() { _routeDef = rd; _myReviews = mine; _loading = false; });
} else {
if (mounted) setState(() => _loading = false);
}
}
void _shareSchedule() {
final auth = context.read<AuthService>();
final dom = auth.primaryDomicilio;
if (dom == null) return;
final rd = _routeDef;
final diasStr = rd?.dias.map(_diaLabel).join(', ') ?? 'Lunes, Miércoles y Viernes';
final horario = rd != null ? '${rd.horaInicio}${rd.horaFin}' : dom.horarioEstimado;
Share.share(
'🗑️ Horario de recolección de basura\n'
'📍 Colonia: ${dom.colonia}\n'
'📅 Días: $diasStr\n'
'⏰ Horario: $horario\n'
'🚛 Ruta: ${dom.routeId}\n\n'
'Descarga Celaya Limpia para recibir avisos en tiempo real.',
);
}
String _diaLabel(String d) {
const m = {'LUNES':'Lu','MARTES':'Ma','MIERCOLES':'Mi',
'JUEVES':'Ju','VIERNES':'Vi','SABADO':'Sa','DOMINGO':'Do'};
return m[d] ?? d;
}
// Días del mes actual con marcas de recolección
List<Widget> _buildCalendar() {
final now = DateTime.now();
final first = DateTime(now.year, now.month, 1);
final days = DateTime(now.year, now.month + 1, 0).day;
final dias = _routeDef?.dias ?? [];
const weekDays = ['LUNES','MARTES','MIERCOLES','JUEVES','VIERNES','SABADO','DOMINGO'];
final monthName = ['','Enero','Febrero','Marzo','Abril','Mayo','Junio',
'Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'][now.month];
return [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text('$monthName ${now.year}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16,
color: AppColors.guindaPrimary)),
),
const SizedBox(height: 8),
// Cabeceras días
Row(children: ['Lu','Ma','Mi','Ju','Vi','Sa','Do'].map((d) =>
Expanded(child: Center(child: Text(d, style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 11, color: AppColors.grisTexto))))).toList()),
const SizedBox(height: 4),
// Grilla de días
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 7, childAspectRatio: 1),
itemCount: (first.weekday - 1) + days,
itemBuilder: (_, i) {
if (i < first.weekday - 1) return const SizedBox();
final day = i - (first.weekday - 1) + 1;
final date = DateTime(now.year, now.month, day);
final diaSem = weekDays[date.weekday - 1];
final isCollection = dias.contains(diaSem);
final isToday = day == now.day;
final isPast = date.isBefore(DateTime(now.year, now.month, now.day));
return Container(
margin: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: isCollection
? (isPast ? AppColors.guindaPrimary.withOpacity(0.4) : AppColors.guindaPrimary)
: (isToday ? Colors.grey.shade200 : null),
shape: BoxShape.circle,
border: isToday ? Border.all(color: AppColors.dorado, width: 2) : null,
),
child: Stack(alignment: Alignment.center, children: [
Text('$day', style: TextStyle(
fontSize: 12,
fontWeight: isToday ? FontWeight.bold : FontWeight.normal,
color: isCollection ? Colors.white : AppColors.negroTexto,
)),
if (isCollection)
Positioned(bottom: 2, child: Container(
width: 4, height: 4,
decoration: const BoxDecoration(color: AppColors.dorado, shape: BoxShape.circle),
)),
]),
);
},
),
];
}
@override
Widget build(BuildContext context) {
final auth = context.read<AuthService>();
final dom = auth.primaryDomicilio;
return Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
title: const Text('Calendario de Recoleccion'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
actions: [
IconButton(icon: const Icon(Icons.share), tooltip: 'Compartir horario',
onPressed: _shareSchedule),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
// Info de la ruta
if (dom != null)
Card(child: Padding(padding: const EdgeInsets.all(14), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
const Row(children: [
Icon(Icons.local_shipping, color: AppColors.guindaPrimary, size: 18),
SizedBox(width: 6),
Text('Tu servicio de recoleccion', style: TextStyle(
fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)),
]),
const Divider(),
Text('Colonia: ${dom.colonia}',
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
Text('Ruta: ${dom.routeId}',
style: const TextStyle(color: AppColors.grisTexto, fontSize: 12)),
if (_routeDef != null) ...[
Text('Dias: ${_routeDef!.dias.map(_diaLabel).join(" · ")}',
style: const TextStyle(fontSize: 12)),
Text('Horario: ${_routeDef!.horaInicio} - ${_routeDef!.horaFin}',
style: const TextStyle(fontSize: 12)),
] else
Text(dom.horarioEstimado,
style: const TextStyle(color: AppColors.grisTexto, fontSize: 12)),
]))),
const SizedBox(height: 16),
// Leyenda
Row(children: [
_Legend(color: AppColors.guindaPrimary, label: 'Dia de recoleccion'),
const SizedBox(width: 12),
_Legend(color: AppColors.dorado, label: 'Punto en dia activo'),
const SizedBox(width: 12),
_Legend(color: Colors.grey.shade200, label: 'Hoy'),
]),
const SizedBox(height: 12),
// Calendario
Card(child: Padding(padding: const EdgeInsets.all(14), child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: _buildCalendar()))),
const SizedBox(height: 16),
// Consejos semanales
Card(color: Colors.blue.shade50,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10),
side: BorderSide(color: Colors.blue.shade200)),
child: Padding(padding: const EdgeInsets.all(14), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
const Row(children: [
Icon(Icons.tips_and_updates, color: AppColors.azulInfo),
SizedBox(width: 8),
Text('Consejo de la semana', style: TextStyle(
fontWeight: FontWeight.bold, color: AppColors.azulInfo, fontSize: 14)),
]),
const SizedBox(height: 8),
Text(_weeklyTip(), style: const TextStyle(fontSize: 13, color: AppColors.negroTexto)),
])),
),
const SizedBox(height: 16),
// Mis calificaciones
if (_myReviews.isNotEmpty) ...[
const Text('Mis calificaciones', style: TextStyle(
fontWeight: FontWeight.bold, fontSize: 15, color: AppColors.guindaPrimary)),
const SizedBox(height: 8),
..._myReviews.take(3).map((r) => Card(margin: const EdgeInsets.only(bottom: 8),
child: ListTile(dense: true,
leading: CircleAvatar(backgroundColor: Colors.amber.shade100,
child: Text('${r.estrellas}', style: const TextStyle(
fontWeight: FontWeight.bold, color: Colors.amber))),
title: Text(r.colonia, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)),
subtitle: Text(r.comentario, maxLines: 1, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11)),
trailing: Text(
'${DateTime.tryParse(r.fecha)?.day}/${DateTime.tryParse(r.fecha)?.month}',
style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
))),
],
const SizedBox(height: 30),
])),
);
}
String _weeklyTip() {
final tips = [
'Separa tus residuos en organicos (restos de comida) e inorganicos (plasticos, metales). Facilita el reciclaje y reduce la contaminacion.',
'Coloca tus bolsas en la acera SOLO cuando recibas el aviso de 15 minutos. Sacarlas antes atrae fauna nociva.',
'El reciclaje de 1 tonelada de papel salva 17 arboles. Dobla tus cajas y periodicos antes de depositarlos.',
'Los aceites usados de cocina NO van a la basura. Llevalos a los puntos de acopio del municipio.',
'Composta tus restos organicos si tienes jardin. Reduce hasta un 40% tu basura y mejora tu suelo.',
'Las pilas y baterias son residuos peligrosos. Depositalas en los contenedores especiales de tiendas.',
'Un celular viejo contiene oro, plata y cobre. Llevalo a un punto RAEE para su reciclaje correcto.',
];
return tips[DateTime.now().weekday % tips.length];
}
}
class _Legend extends StatelessWidget {
final Color color; final String label;
const _Legend({required this.color, required this.label});
@override
Widget build(BuildContext context) => Row(mainAxisSize: MainAxisSize.min, children: [
Container(width: 12, height: 12, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
const SizedBox(width: 4),
Text(label, style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
]);
}

View File

@@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/app_colors.dart';
import '../../database/db_helper.dart';
import '../../services/auth_service.dart';
class NotificationHistoryScreen extends StatefulWidget {
const NotificationHistoryScreen({super.key});
@override State<NotificationHistoryScreen> createState() => _NotificationHistoryScreenState();
}
class _NotificationHistoryScreenState extends State<NotificationHistoryScreen> {
List<Map<String, dynamic>> _notifs = [];
bool _loading = true;
@override
void initState() { super.initState(); _load(); }
Future<void> _load() async {
final auth = context.read<AuthService>();
if (auth.currentUser == null) return;
final n = await DbHelper.getNotifHistory(auth.currentUser!.id!);
await DbHelper.markAllNotifsRead(auth.currentUser!.id!);
if (mounted) setState(() { _notifs = n; _loading = false; });
}
Color _color(String type) {
switch (type) {
case 'truckProximity':
case 'truckApproaching15min': return AppColors.naranjaAlerta;
case 'routeCompleted':
case 'reviewPrompt': return AppColors.verdeExito;
case 'routeCancelled': return AppColors.rojoError;
case 'gpsLost': return Colors.red.shade800;
case 'truckStopped': return AppColors.naranjaAlerta;
default: return AppColors.azulInfo;
}
}
IconData _icon(String type) {
switch (type) {
case 'truckProximity':
case 'truckApproaching15min': return Icons.warning_amber_rounded;
case 'routeCompleted': return Icons.check_circle;
case 'reviewPrompt': return Icons.star;
case 'routeCancelled': return Icons.cancel;
case 'gpsLost': return Icons.gps_off;
default: return Icons.notifications;
}
}
String _timeAgo(String fechaStr) {
final f = DateTime.tryParse(fechaStr);
if (f == null) return '';
final diff = DateTime.now().difference(f);
if (diff.inMinutes < 1) return 'Ahora';
if (diff.inMinutes < 60) return 'Hace ${diff.inMinutes} min';
if (diff.inHours < 24) return 'Hace ${diff.inHours}h';
return '${f.day}/${f.month}/${f.year}';
}
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
title: const Text('Historial de Alertas'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
actions: [
TextButton(
onPressed: () async {
await DbHelper.markAllNotifsRead(
context.read<AuthService>().currentUser!.id!);
setState(() {});
},
child: const Text('Marcar leídas', style: TextStyle(color: AppColors.dorado, fontSize: 12)),
),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _notifs.isEmpty
? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.notifications_none, color: Colors.grey.shade400, size: 64),
const SizedBox(height: 12),
Text('Sin alertas registradas', style: TextStyle(color: Colors.grey.shade500)),
]))
: ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: _notifs.length,
itemBuilder: (_, i) {
final n = _notifs[i];
final isUnread = (n['leida'] as int?) == 0;
final color = _color(n['event_type'] ?? '');
return Container(
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: isUnread ? color.withOpacity(0.05) : Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isUnread ? color.withOpacity(0.3) : Colors.grey.shade200),
),
child: ListTile(
leading: CircleAvatar(
backgroundColor: color.withOpacity(0.15),
child: Icon(_icon(n['event_type'] ?? ''), color: color, size: 20),
),
title: Text(n['title'] ?? '', style: TextStyle(
fontWeight: isUnread ? FontWeight.bold : FontWeight.normal,
fontSize: 13)),
subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(n['body'] ?? '', style: const TextStyle(fontSize: 11), maxLines: 2,
overflow: TextOverflow.ellipsis),
const SizedBox(height: 2),
Text('${n['route_id']} · ${_timeAgo(n['fecha'] ?? '')}',
style: TextStyle(fontSize: 10, color: color.withOpacity(0.7))),
]),
trailing: isUnread
? Container(width: 8, height: 8,
decoration: BoxDecoration(color: color, shape: BoxShape.circle))
: null,
),
);
}),
);
}

View File

@@ -0,0 +1,183 @@
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';
import '../../services/route_simulator_service.dart';
class ReviewScreen extends StatefulWidget {
final String routeId;
final String colonia;
const ReviewScreen({super.key, required this.routeId, required this.colonia});
@override State<ReviewScreen> createState() => _ReviewScreenState();
}
class _ReviewScreenState extends State<ReviewScreen> {
int _estrellas = 5;
final _comentCtrl = TextEditingController();
bool _loading = false;
bool _sent = false;
static const _labels = ['', 'Muy malo', 'Malo', 'Regular', 'Bueno', 'Excelente'];
static const _colors = [
Colors.transparent, AppColors.rojoError, AppColors.naranjaAlerta,
Colors.amber, AppColors.verdeExito, AppColors.verdeExito,
];
Future<void> _enviar() async {
final auth = context.read<AuthService>();
if (auth.currentUser == null) return;
// Verificar si ya calificó hoy
final yaCalificado = await DbHelper.hasReviewedRoute(
auth.currentUser!.id!, widget.routeId);
if (yaCalificado && mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Ya calificaste este servicio hoy'),
backgroundColor: AppColors.azulInfo));
return;
}
setState(() => _loading = true);
await DbHelper.insertReview(ReviewModel(
userId: auth.currentUser!.id!,
colonia: widget.colonia,
routeId: widget.routeId,
estrellas: _estrellas,
comentario: _comentCtrl.text.trim().isEmpty
? 'Sin comentario' : _comentCtrl.text.trim(),
fecha: DateTime.now().toIso8601String(),
nombreUsuario: auth.currentUser!.nombre,
));
context.read<RouteSimulatorService>().clearReviewPrompt(widget.routeId);
if (!mounted) return;
setState(() { _loading = false; _sent = true; });
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
title: const Text('Calificar el Servicio'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
),
body: _sent
? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
const Text('', style: TextStyle(fontSize: 64)),
const SizedBox(height: 16),
const Text('¡Gracias por tu calificación!',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold,
color: AppColors.guindaPrimary)),
const SizedBox(height: 8),
const Text('Tu opinión ayuda a mejorar el servicio\nde recolección en Celaya.',
textAlign: TextAlign.center,
style: TextStyle(color: AppColors.grisTexto)),
const SizedBox(height: 24),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white),
onPressed: () => Navigator.pop(context),
child: const Text('Volver al inicio')),
]))
: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(children: [
// Header
Container(
width: double.infinity, padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.guindaPrimary.withOpacity(0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.guindaPrimary.withOpacity(0.2))),
child: Column(children: [
const Icon(Icons.local_shipping, color: AppColors.guindaPrimary, size: 36),
const SizedBox(height: 8),
Text(widget.routeId, style: const TextStyle(
fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)),
Text(widget.colonia, style: const TextStyle(
color: AppColors.grisTexto, fontSize: 12)),
]),
),
const SizedBox(height: 24),
// Estrellas
const Text('¿Cómo calificarías el servicio de hoy?',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const SizedBox(height: 16),
Row(mainAxisAlignment: MainAxisAlignment.center, children: List.generate(5, (i) {
final star = i + 1;
return GestureDetector(
onTap: () => setState(() => _estrellas = star),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Icon(
_estrellas >= star ? Icons.star : Icons.star_border,
color: _estrellas >= star ? Colors.amber : Colors.grey,
size: 44,
),
),
);
})),
const SizedBox(height: 8),
Text(_labels[_estrellas],
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold,
color: _colors[_estrellas])),
const SizedBox(height: 24),
// Comentario
const Align(alignment: Alignment.centerLeft,
child: Text('Comentario (opcional)',
style: TextStyle(fontWeight: FontWeight.w600))),
const SizedBox(height: 8),
TextField(
controller: _comentCtrl,
maxLines: 4,
maxLength: 200,
decoration: const InputDecoration(
hintText: 'Cuéntanos cómo estuvo el servicio...',
border: OutlineInputBorder(),
filled: true, fillColor: Colors.white),
),
const SizedBox(height: 20),
// Aviso
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.shade200)),
child: const Row(children: [
Icon(Icons.info_outline, color: AppColors.azulInfo, size: 16),
SizedBox(width: 6),
Expanded(child: Text(
'Tu calificación es anónima para otros ciudadanos, '
'pero el Ayuntamiento la usará para mejorar el servicio.',
style: TextStyle(fontSize: 11, color: AppColors.azulInfo))),
]),
),
const SizedBox(height: 24),
SizedBox(width: double.infinity, height: 50,
child: ElevatedButton.icon(
onPressed: _loading ? null : _enviar,
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.star),
label: const Text('ENVIAR CALIFICACIÓN',
style: TextStyle(fontWeight: FontWeight.bold)))),
]),
),
);
}
@override void dispose() { _comentCtrl.dispose(); super.dispose(); }
}

View File

@@ -0,0 +1,456 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/app_colors.dart';
import '../../services/auth_service.dart';
import '../../services/route_simulator_service.dart';
import '../../database/db_helper.dart';
import '../../models/models.dart';
import '../../data/routes_data.dart';
import '../../widgets/route_map_widget.dart';
import '../settings_screen.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:DriverRouteMap(route:route,simulator:sim));
}
// ── Tab reporte incidente — usa routeId actual ────────────────────────────
class _DriverReportesTab extends StatefulWidget {
final int? conductorId;
final String? todayRouteId; // Ruta actual del conductor
const _DriverReportesTab({required this.conductorId, required this.todayRouteId});
@override State<_DriverReportesTab> createState() => _DriverReportesTabState();
}
class _DriverReportesTabState extends State<_DriverReportesTab> {
String _tipo = 'INCIDENTE_LLANTA';
final _desc = TextEditingController();
bool _loading = false, _sent = false;
List<AlertaModel> _misIncidentes = [];
static const _tipos = {
'INCIDENTE_LLANTA': '🔧 Llanta ponchada',
'INCIDENTE_MECANICA': '🔥 Falla mecánica',
'INCIDENTE_ACCIDENTE': '🚑 Accidente',
'INCIDENTE_CAMINO': '🚧 Camino bloqueado',
'INCIDENTE_COMBUSTIBLE':'⛽ Sin combustible',
'INCIDENTE_OTRO': '📝 Otro',
};
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final all = await DbHelper.getAlertas();
// Solo incidentes de la ruta actual del conductor
final mine = all.where((a) =>
a.tipo.startsWith('INCIDENTE_') &&
a.routeId == (widget.todayRouteId ?? '')).toList();
if (mounted) setState(() => _misIncidentes = mine);
}
Future<void> _enviar() async {
if (widget.todayRouteId == null) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content:Text('No tienes ruta asignada hoy'),
backgroundColor:AppColors.rojoError)); return;
}
if (_desc.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content:Text('Describe el incidente'),backgroundColor:AppColors.rojoError)); return;
}
setState(()=>_loading=true);
// Guardar el incidente asociado a la RUTA ACTUAL
await DbHelper.insertAlerta(AlertaModel(
tipo: _tipo,
routeId: widget.todayRouteId!, // ← ID de la ruta actual, no del conductor
mensaje: '${_tipos[_tipo]}: ${_desc.text.trim()}',
fecha: DateTime.now().toIso8601String(),
));
await _load();
if (!mounted) return;
setState(() { _loading=false; _sent=true; });
_desc.clear();
await Future.delayed(const Duration(seconds:2));
if (mounted) setState(()=>_sent=false);
}
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor:AppColors.grisFondo,
appBar:AppBar(automaticallyImplyLeading:false,
backgroundColor:AppColors.moradoConductor,foregroundColor:Colors.white,
title:const Text('Reportar Incidente'),
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
child:Container(height:4,color:AppColors.dorado))),
body: _sent
? const Center(child:Column(mainAxisAlignment:MainAxisAlignment.center,children:[
Icon(Icons.check_circle,color:AppColors.verdeExito,size:64),
SizedBox(height:12),
Text('¡Incidente reportado!',style:TextStyle(fontSize:18,fontWeight:FontWeight.bold,color:AppColors.verdeExito)),
Text('El administrador será notificado.',style:TextStyle(color:AppColors.grisTexto)),
]))
: SingleChildScrollView(padding:const EdgeInsets.all(16),child:Column(children:[
// Info ruta actual
if (widget.todayRouteId != null)
Container(margin:const EdgeInsets.only(bottom:12),
padding:const EdgeInsets.all(10),
decoration:BoxDecoration(color:AppColors.moradoConductor.withOpacity(0.08),
borderRadius:BorderRadius.circular(8),
border:Border.all(color:AppColors.moradoConductor.withOpacity(0.3))),
child:Row(children:[
const Icon(Icons.route,color:AppColors.moradoConductor,size:16),
const SizedBox(width:6),
Text('Incidente en: ${widget.todayRouteId}',
style:const TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor,fontSize:13)),
]))
else
Container(margin:const EdgeInsets.only(bottom:12),
padding:const EdgeInsets.all(10),
decoration:BoxDecoration(color:Colors.orange.shade50,borderRadius:BorderRadius.circular(8)),
child:const Text('⚠️ No tienes ruta asignada hoy',
style:TextStyle(color:AppColors.naranjaAlerta,fontWeight:FontWeight.bold))),
Card(shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(12)),
child:Padding(padding:const EdgeInsets.all(16),child:Column(
crossAxisAlignment:CrossAxisAlignment.start, children:[
const Text('Tipo de incidente',
style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor,fontSize:15)),
const SizedBox(height:8),
..._tipos.entries.map((e)=>RadioListTile<String>(dense:true,
value:e.key,groupValue:_tipo,
title:Text(e.value,style:const TextStyle(fontSize:13)),
activeColor:AppColors.moradoConductor,
onChanged:(v)=>setState(()=>_tipo=v!))),
const SizedBox(height:10),
const Text('Descripción',style:TextStyle(fontWeight:FontWeight.w600,fontSize:13)),
const SizedBox(height:6),
TextField(controller:_desc,maxLines:3,
decoration:const InputDecoration(hintText:'Describe qué pasó...',
border:OutlineInputBorder(),filled:true,fillColor:Colors.white)),
const SizedBox(height:12),
Container(padding:const EdgeInsets.all(10),
decoration:BoxDecoration(color:Colors.orange.shade50,
borderRadius:BorderRadius.circular(8),
border:Border.all(color:Colors.orange.shade200)),
child:const Text(
'⚠️ El administrador verá este incidente en tu ruta actual '
'y decidirá si continúa, se retrasa o se cancela.',
style:TextStyle(fontSize:11,color:Colors.black87))),
const SizedBox(height:14),
SizedBox(width:double.infinity,height:48,
child:ElevatedButton.icon(
onPressed:(_loading||widget.todayRouteId==null)?null:_enviar,
style:ElevatedButton.styleFrom(backgroundColor:AppColors.moradoConductor,
foregroundColor:Colors.white,
shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(8))),
icon:_loading?const SizedBox(width:18,height:18,
child:CircularProgressIndicator(color:Colors.white,strokeWidth:2))
:const Icon(Icons.send),
label:const Text('ENVIAR INCIDENTE',style:TextStyle(fontWeight:FontWeight.bold)))),
]))),
if (_misIncidentes.isNotEmpty)...[
const SizedBox(height:16),
const Align(alignment:Alignment.centerLeft,
child:Text('Mis incidentes de hoy',
style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor,fontSize:14))),
const SizedBox(height:8),
..._misIncidentes.take(5).map((a)=>Card(margin:const EdgeInsets.only(bottom:6),
child:ListTile(dense:true,
leading:CircleAvatar(backgroundColor:AppColors.moradoConductor,radius:16,
child:const Icon(Icons.warning,color:Colors.white,size:14)),
title:Text(_tipos[a.tipo]??a.tipo,
style:const TextStyle(fontSize:12,fontWeight:FontWeight.w600)),
subtitle:Text(a.mensaje,maxLines:1,overflow:TextOverflow.ellipsis,
style:const TextStyle(fontSize:11)),
trailing:Icon(a.resuelta?Icons.check_circle:Icons.pending,
color:a.resuelta?AppColors.verdeExito:AppColors.naranjaAlerta,size:18)))),
],
])),
);
@override void dispose(){ _desc.dispose(); super.dispose(); }
}
// ── Notif banner conductor ────────────────────────────────────────────────
class _NotifBanner extends StatelessWidget {
final AppNotification notif; final VoidCallback onDismiss;
const _NotifBanner({required this.notif, required this.onDismiss});
@override
Widget build(BuildContext context) {
final color = notif.event==NotifEvent.gpsLost?Colors.red.shade800
:notif.event==NotifEvent.truckStopped?AppColors.naranjaAlerta
:notif.event==NotifEvent.routeCancelled?AppColors.rojoError
:AppColors.moradoConductor;
return Material(color:Colors.transparent,
child:Container(margin:const EdgeInsets.all(10),
decoration:BoxDecoration(color:color,borderRadius:BorderRadius.circular(12),
boxShadow:const[BoxShadow(color:Colors.black26,blurRadius:6)]),
child:Padding(padding:const EdgeInsets.all(12),child:Row(children:[
const Icon(Icons.notification_important,color:Colors.white,size:22),
const SizedBox(width:8),
Expanded(child:Column(crossAxisAlignment:CrossAxisAlignment.start,
mainAxisSize:MainAxisSize.min,children:[
Text(notif.title,style:const TextStyle(color:Colors.white,fontWeight:FontWeight.bold,fontSize:13)),
Text(notif.body,style:const TextStyle(color:Colors.white70,fontSize:11),
maxLines:2,overflow:TextOverflow.ellipsis),
])),
IconButton(icon:const Icon(Icons.close,color:Colors.white,size:18),onPressed:onDismiss),
]))));
}
}

View File

@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../core/app_colors.dart';
import '../services/auth_service.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _emailCtrl = TextEditingController();
final _passCtrl = TextEditingController();
bool _loading = false, _obscure = true;
Future<void> _login() async {
if (_emailCtrl.text.isEmpty || _passCtrl.text.isEmpty) {
_snack('Llena todos los campos', isError: true); return;
}
setState(() => _loading = true);
final err = await context.read<AuthService>().login(_emailCtrl.text, _passCtrl.text);
if (!mounted) return;
setState(() => _loading = false);
if (err != null) { _snack(err, isError: true); return; }
final rol = context.read<AuthService>().rol;
switch (rol) {
case 'ADMINISTRADOR': Navigator.pushReplacementNamed(context, '/admin'); break;
case 'CONDUCTOR': Navigator.pushReplacementNamed(context, '/driver'); break;
default: Navigator.pushReplacementNamed(context, '/home'); break;
}
}
void _snack(String msg, {bool isError = false}) => ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content:Text(msg),
backgroundColor: isError ? AppColors.rojoError : AppColors.verdeExito));
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: AppColors.grisFondo,
body: SingleChildScrollView(child: Column(children: [
Container(width:double.infinity, color:AppColors.guindaPrimary,
padding:const EdgeInsets.only(top:60,bottom:28),
child:Column(children:[
Container(width:84,height:84,
decoration:BoxDecoration(color:Colors.white12,shape:BoxShape.circle,
border:Border.all(color:AppColors.dorado,width:2.5)),
child:const Icon(Icons.delete_sweep_rounded,size:44,color:AppColors.dorado)),
const SizedBox(height:14),
const Text('H. AYUNTAMIENTO DE CELAYA',
style:TextStyle(color:Colors.white,fontSize:13,fontWeight:FontWeight.bold,letterSpacing:1.2)),
const SizedBox(height:4),
const Text('Sistema de Recolección de Residuos',
style:TextStyle(color:AppColors.dorado,fontSize:13)),
])),
Container(height:4,color:AppColors.dorado),
Padding(padding:const EdgeInsets.all(24), child:Card(elevation:4,
shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(12)),
child:Padding(padding:const EdgeInsets.all(24), child:Column(
crossAxisAlignment:CrossAxisAlignment.start, children:[
const Text('Iniciar Sesión',style:TextStyle(fontSize:20,
fontWeight:FontWeight.bold,color:AppColors.guindaPrimary)),
const SizedBox(height:16),
// Accesos rápidos demo
Container(padding:const EdgeInsets.all(10),
decoration:BoxDecoration(color:Colors.blue.shade50,borderRadius:BorderRadius.circular(8)),
child:const Column(crossAxisAlignment:CrossAxisAlignment.start, children:[
Text('Demo rápido:',style:TextStyle(fontWeight:FontWeight.bold,fontSize:12,color:AppColors.azulInfo)),
Text('Admin: admin@celaya.gob.mx / admin123',style:TextStyle(fontSize:11)),
Text('Conductor: conductor@celaya.gob.mx / conductor123',style:TextStyle(fontSize:11)),
])),
const SizedBox(height:16),
TextField(controller:_emailCtrl,keyboardType:TextInputType.emailAddress,
decoration:const InputDecoration(labelText:'Correo electrónico',
prefixIcon:Icon(Icons.email_outlined,color:AppColors.guindaPrimary),
border:OutlineInputBorder())),
const SizedBox(height:12),
TextField(controller:_passCtrl,obscureText:_obscure,
decoration:InputDecoration(labelText:'Contraseña',
prefixIcon:const Icon(Icons.lock_outline,color:AppColors.guindaPrimary),
border:const OutlineInputBorder(),
suffixIcon:IconButton(icon:Icon(_obscure?Icons.visibility_off:Icons.visibility),
onPressed:()=>setState(()=>_obscure=!_obscure)))),
const SizedBox(height:20),
SizedBox(width:double.infinity,height:50,
child:ElevatedButton(onPressed:_loading?null:_login,
style:ElevatedButton.styleFrom(backgroundColor:AppColors.guindaPrimary,
foregroundColor:Colors.white,shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(8))),
child:_loading?const CircularProgressIndicator(color:Colors.white,strokeWidth:2)
:const Text('ENTRAR',style:TextStyle(fontWeight:FontWeight.bold,letterSpacing:1)))),
const SizedBox(height:12),
const Divider(),
const SizedBox(height:12),
SizedBox(width:double.infinity,height:50,
child:OutlinedButton(onPressed:()=>Navigator.pushNamed(context,'/register'),
style:OutlinedButton.styleFrom(foregroundColor:AppColors.guindaPrimary,
side:const BorderSide(color:AppColors.guindaPrimary),
shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(8))),
child:const Text('CREAR CUENTA CIUDADANO',style:TextStyle(fontWeight:FontWeight.bold)))),
])))),
const Padding(padding:EdgeInsets.only(bottom:20),
child:Text('Gobierno Municipal de Celaya • Guanajuato',
style:TextStyle(color:AppColors.grisTexto,fontSize:11))),
])),
);
@override void dispose() { _emailCtrl.dispose(); _passCtrl.dispose(); super.dispose(); }
}

View File

@@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../core/app_colors.dart';
class OnboardingScreen extends StatefulWidget {
const OnboardingScreen({super.key});
@override State<OnboardingScreen> createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends State<OnboardingScreen> {
final _ctrl = PageController();
int _page = 0;
static const _pages = [
_OnboardPage(icon:Icons.delete_sweep_rounded, color:AppColors.guindaPrimary,
title:'Bienvenido a Celaya Limpia',
subtitle:'El sistema de recoleccion inteligente del H. Ayuntamiento de Celaya.',
desc:'Recibe alertas en tiempo real, conoce tu horario y ayuda a mantener tu ciudad limpia.'),
_OnboardPage(icon:Icons.notifications_active, color:AppColors.azulInfo,
title:'Alertas inteligentes',
subtitle:'Te avisamos exactamente cuando debes sacar tu basura.',
desc:'Recibiras 3 alertas:\n\n30 min antes: el camion esta en camino.\n15 min antes: saca tus bolsas a la acera.\nAl pasar: confirma que fue recogida.\n\nNunca mas pierdas al camion recolector.'),
_OnboardPage(icon:Icons.recycling, color:AppColors.verdeExito,
title:'Guia de separacion',
subtitle:'Aprende a separar correctamente tus residuos.',
desc:'Bolsa verde: organicos (comida, jardin)\nBolsa azul: reciclables (PET, latas)\nBolsa negra: no reciclables\n\nUsa la camara IA para identificar si un residuo es organico o inorganico al instante.'),
_OnboardPage(icon:Icons.star, color:Colors.amber,
title:'Tu opinion importa',
subtitle:'Califica el servicio y ayuda a mejorarlo.',
desc:'Despues de cada recoleccion podras:\n\nCalificar de 1 a 5 estrellas\nDejar comentarios al Ayuntamiento\nReportar incidencias con foto\n\nTus reportes son atendidos por el equipo municipal.'),
];
Future<void> _finish() async {
final p = await SharedPreferences.getInstance();
await p.setBool('onboarding_done', true);
if (!mounted) return;
Navigator.pushReplacementNamed(context, '/login');
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(children: [
PageView.builder(controller:_ctrl, onPageChanged:(p)=>setState(()=>_page=p),
itemCount:_pages.length,
itemBuilder:(_,i)=>_PageContent(page:_pages[i])),
Positioned(bottom:120, left:0, right:0,
child:Row(mainAxisAlignment:MainAxisAlignment.center,
children:List.generate(_pages.length,(i)=>AnimatedContainer(
duration:const Duration(milliseconds:250),
margin:const EdgeInsets.symmetric(horizontal:4),
width:_page==i?24:8, height:8,
decoration:BoxDecoration(
color:_page==i?_pages[i].color:Colors.grey.shade300,
borderRadius:BorderRadius.circular(4)))))),
Positioned(bottom:40, left:24, right:24,
child:Row(children:[
if (_page>0)
TextButton(onPressed:()=>_ctrl.previousPage(
duration:const Duration(milliseconds:300),curve:Curves.easeOut),
child:const Text('Atras',style:TextStyle(color:AppColors.grisTexto)))
else
TextButton(onPressed:_finish,
child:const Text('Omitir',style:TextStyle(color:AppColors.grisTexto))),
const Spacer(),
ElevatedButton(
onPressed:_page<_pages.length-1
?()=>_ctrl.nextPage(duration:const Duration(milliseconds:300),curve:Curves.easeOut)
:_finish,
style:ElevatedButton.styleFrom(
backgroundColor:_pages[_page].color, foregroundColor:Colors.white,
padding:const EdgeInsets.symmetric(horizontal:28,vertical:12),
shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(25))),
child:Text(_page<_pages.length-1?'Siguiente':'Comenzar',
style:const TextStyle(fontWeight:FontWeight.bold))),
])),
]));
}
@override void dispose(){ _ctrl.dispose(); super.dispose(); }
}
class _OnboardPage {
final IconData icon; final Color color;
final String title, subtitle, desc;
const _OnboardPage({required this.icon,required this.color,
required this.title,required this.subtitle,required this.desc});
}
class _PageContent extends StatelessWidget {
final _OnboardPage page;
const _PageContent({super.key, required this.page});
@override
Widget build(BuildContext context) => Container(
decoration:BoxDecoration(gradient:LinearGradient(
begin:Alignment.topCenter, end:Alignment.bottomCenter,
colors:[page.color, page.color.withOpacity(0.85), Colors.white],
stops:const[0,0.4,0.7])),
child:SafeArea(child:Column(children:[
const SizedBox(height:48),
Container(width:120,height:120,
decoration:BoxDecoration(color:Colors.white.withOpacity(0.2),shape:BoxShape.circle,
border:Border.all(color:Colors.white.withOpacity(0.5),width:2)),
child:Icon(page.icon,size:60,color:Colors.white)),
const SizedBox(height:28),
Padding(padding:const EdgeInsets.symmetric(horizontal:32),child:Column(children:[
Text(page.title,textAlign:TextAlign.center,
style:const TextStyle(fontSize:24,fontWeight:FontWeight.bold,color:Colors.white)),
const SizedBox(height:10),
Text(page.subtitle,textAlign:TextAlign.center,
style:TextStyle(fontSize:14,color:Colors.white.withOpacity(0.9))),
])),
const SizedBox(height:32),
Expanded(child:Container(margin:const EdgeInsets.symmetric(horizontal:20),
padding:const EdgeInsets.all(22),
decoration:BoxDecoration(color:Colors.white,borderRadius:BorderRadius.circular(20),
boxShadow:[BoxShadow(color:page.color.withOpacity(0.2),blurRadius:20,offset:const Offset(0,8))]),
child:Text(page.desc,textAlign:TextAlign.left,
style:const TextStyle(fontSize:13,height:1.7,color:AppColors.negroTexto)))),
const SizedBox(height:110),
])));
}

View File

@@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../core/app_colors.dart';
import '../data/colonies_data.dart';
import '../data/celaya_colonias.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:celayaColonias.map((n)=>DropdownMenuItem(value:n,child:Text(n,style:const TextStyle(fontSize:13)))).toList(),
onChanged:(v){ if(v!=null) setState((){
_colony = getColonyByName(v) ?? ColonyModel(
colonia:v, routeId:'RUTA-01', horarioEstimado:'Matutino (06:00-08:00)');
}); }),
if (_colony!=null) ...[
const SizedBox(height:10),
Container(padding:const EdgeInsets.all(12),
decoration:BoxDecoration(color:AppColors.guindaPrimary.withOpacity(0.08),
borderRadius:BorderRadius.circular(8),
border:Border.all(color:AppColors.guindaPrimary.withOpacity(0.3))),
child:Column(crossAxisAlignment:CrossAxisAlignment.start,children:[
Text('Ruta: ${_colony!.routeId}',style:const TextStyle(color:AppColors.guindaPrimary,fontWeight:FontWeight.bold)),
Text('Horario: ${_colony!.horarioEstimado}',style:const TextStyle(color:AppColors.grisTexto,fontSize:12)),
]))],
const SizedBox(height:24),
SizedBox(width:double.infinity,height:50,
child:ElevatedButton(onPressed:_loading?null:_register,
style:ElevatedButton.styleFrom(backgroundColor:AppColors.guindaPrimary,
foregroundColor:Colors.white,shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(8))),
child:_loading?const CircularProgressIndicator(color:Colors.white,strokeWidth:2)
:const Text('REGISTRARME',style:TextStyle(fontWeight:FontWeight.bold,letterSpacing:1)))),
const SizedBox(height:20),
])),
);
Widget _field(TextEditingController c, String label, IconData icon,
{TextInputType type=TextInputType.text}) =>
TextField(controller:c,keyboardType:type,
decoration:InputDecoration(labelText:label,
prefixIcon:Icon(icon,color:AppColors.guindaPrimary),
border:const OutlineInputBorder(),filled:true,fillColor:Colors.white));
@override void dispose(){ _nombre.dispose();_email.dispose();_pass.dispose();_calle.dispose();super.dispose(); }
}

View File

@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../core/app_colors.dart';
import '../services/theme_service.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context) {
final theme = context.watch<ThemeService>();
return Scaffold(
appBar: AppBar(title: const Text('Configuracion'),
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado))),
body: ListView(padding: const EdgeInsets.all(16), children: [
Card(child: Column(children: [
const Padding(padding: EdgeInsets.all(14),
child: Row(children: [
Icon(Icons.palette_outlined, color: AppColors.guindaPrimary),
SizedBox(width: 8),
Text('Apariencia', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15)),
])),
const Divider(height: 1),
SwitchListTile(
value: theme.isDark,
onChanged: (_) => theme.toggle(),
secondary: Icon(theme.isDark ? Icons.dark_mode : Icons.light_mode,
color: theme.isDark ? Colors.amber : AppColors.guindaPrimary),
title: Text(theme.isDark ? 'Modo oscuro activo' : 'Modo claro activo'),
subtitle: const Text('Util para rutas nocturnas'),
activeColor: AppColors.guindaPrimary,
),
])),
const SizedBox(height: 12),
Card(child: Column(children: [
const Padding(padding: EdgeInsets.all(14),
child: Row(children: [
Icon(Icons.info_outline, color: AppColors.guindaPrimary),
SizedBox(width: 8),
Text('Acerca de', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15)),
])),
const Divider(height: 1),
const ListTile(leading: Icon(Icons.location_city), title: Text('H. Ayuntamiento de Celaya'),
subtitle: Text('Guanajuato, Mexico')),
const ListTile(leading: Icon(Icons.code), title: Text('Version 2.0.0'),
subtitle: Text('Sistema Integral de Recoleccion de Residuos')),
])),
]),
);
}
}

View File

@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../core/app_colors.dart';
import '../services/auth_service.dart';
import '../services/route_simulator_service.dart';
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@override State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen> {
@override
void initState() { super.initState(); _go(); }
Future<void> _go() async {
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return;
final auth = context.read<AuthService>();
context.read<RouteSimulatorService>().startAllRoutes();
if (auth.isLoggedIn) {
_navigate(auth.rol);
} else {
Navigator.pushReplacementNamed(context, '/login');
}
}
void _navigate(String rol) {
switch (rol) {
case 'ADMINISTRADOR': Navigator.pushReplacementNamed(context, '/admin'); break;
case 'CONDUCTOR': Navigator.pushReplacementNamed(context, '/driver'); break;
default: Navigator.pushReplacementNamed(context, '/home'); break;
}
}
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: AppColors.guindaPrimary,
body: Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Container(width:100,height:100,
decoration:BoxDecoration(color:Colors.white12,shape:BoxShape.circle,
border:Border.all(color:AppColors.dorado,width:3)),
child:const Icon(Icons.delete_sweep_rounded,size:52,color:AppColors.dorado)),
const SizedBox(height:20),
const Text('CELAYA LIMPIA',style:TextStyle(color:Colors.white,fontSize:26,
fontWeight:FontWeight.bold,letterSpacing:2)),
const SizedBox(height:4),
const Text('H. Ayuntamiento de Celaya',style:TextStyle(color:Colors.white60,fontSize:13)),
const SizedBox(height:40),
const CircularProgressIndicator(valueColor:AlwaysStoppedAnimation<Color>(AppColors.dorado)),
])),
);
}

View File

@@ -0,0 +1,79 @@
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? _primaryDomicilio;
List<DomicilioModel> _allDomicilios = [];
bool _loading = true;
UserModel? get currentUser => _user;
DomicilioModel? get primaryDomicilio => _primaryDomicilio;
List<DomicilioModel> get allDomicilios => _allDomicilios;
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') await reloadDomicilios();
}
_loading = false;
notifyListeners();
}
Future<void> reloadDomicilios() async {
if (_user == null) return;
_allDomicilios = await DbHelper.getDomiciliosByUser(_user!.id!);
_primaryDomicilio = _allDomicilios.isNotEmpty
? _allDomicilios.firstWhere((d) => d.isPrimary,
orElse: () => _allDomicilios.first)
: null;
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') await reloadDomicilios();
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, alias:'Casa',
calle:calle.trim(), colonia:colonia, routeId:routeId,
horarioEstimado:horarioEstimado, isPrimary:true));
_user = await DbHelper.getUserById(uid);
await reloadDomicilios();
final p = await SharedPreferences.getInstance();
await p.setInt('user_id', uid);
notifyListeners();
return null;
}
Future<void> logout() async {
_user = null; _primaryDomicilio = null; _allDomicilios = [];
final p = await SharedPreferences.getInstance();
await p.remove('user_id');
notifyListeners();
}
}

View File

@@ -0,0 +1,237 @@
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, truckApproaching15min, routeCompleted,
gpsLost, truckStopped, routeCancelled, reviewPrompt, none
}
class AppNotification {
final NotifEvent event;
final String title;
final String body;
final String routeId;
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;
bool reviewPromptSent;
SimulatorState({required this.routeId, this.positionIndex=0,
this.gpsActive=true, required this.lastMoved,
this.stoppedAlertSent=false, this.reviewPromptSent=false});
}
class RouteSimulatorService extends ChangeNotifier {
final Map<String, SimulatorState> _states = {};
Timer? _globalTimer;
Timer? _gpsMonitorTimer;
AppNotification? _lastNotification;
final List<AppNotification> _history = [];
// ── Getters ──────────────────────────────────────────────────────────────
AppNotification? get lastNotification => _lastNotification;
// Ciudadano/Conductor: solo su ruta
AppNotification? getNotificationForRoute(String routeId) {
if (_lastNotification?.routeId == routeId) return _lastNotification;
return null;
}
List<AppNotification> get history => List.unmodifiable(_history);
List<AppNotification> historyForRoute(String routeId) =>
_history.where((n) => n.routeId == routeId).toList();
bool needsReviewPrompt(String routeId) =>
_states[routeId]?.reviewPromptSent == true;
// ── 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;
_fireAndSave(event:NotifEvent.truckStopped, routeId:state.routeId,
title:'⚠️ Camión detenido',
body:'El camión ${state.routeId} lleva +30 min sin moverse. Verifica.',
tipo:'CAMION_DETENIDO');
}
}
}
void _checkNotification(SimulatorState state, RouteModel route) {
final idx = state.positionIndex;
final total = route.positions.length;
if (idx == 1) {
// Ruta iniciada
_fireNotif(NotifEvent.routeStart, '¡Ruta Iniciada! 🚛',
'El camión ha salido del Relleno Sanitario rumbo a tu sector. '
'Prepara tus bolsas pero espera la señal para sacarlas.', state.routeId);
} else if (idx == 2) {
// ~30 min — aviso preventivo
_fireNotif(NotifEvent.truckApproaching15min, '🕐 El camión se acerca',
'Tu camión recolector está en camino. Tendrás otro aviso cuando esté a '
'15 minutos. ⚠️ No saques la basura todavía — espera el aviso.', state.routeId);
} else if (idx == 3) {
// ~15 min — MOMENTO de sacar la basura
_fireNotif(NotifEvent.truckProximity, '⚠️ ¡Saca tus bolsas AHORA!',
'El camión llega en aprox. 15 minutos a tu colonia. '
'Este es el momento de sacar tus bolsas a la acera. '
'🚫 No persigas ni interceptes la unidad.', state.routeId);
} else if (idx == total - 2) {
// Pasando por la zona
_fireNotif(NotifEvent.truckProximity, '✅ El camión está en tu zona',
'El camión recolector está pasando por tu colonia. '
'Si ya sacaste tus bolsas, el servicio está en curso.', state.routeId);
} else if (idx == total - 1) {
// Servicio finalizado → prompt de reseña
state.reviewPromptSent = true;
_fireNotif(NotifEvent.reviewPrompt, '🌟 ¿Cómo fue el servicio?',
'¡El camión concluyó su jornada! Ayúdanos calificando el servicio '
'de recolección de hoy. Tu opinión mejora el servicio.', 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);
// Persistir en DB para historial
DbHelper.insertNotifHistory(
routeId: routeId, eventType: event.name,
title: title, body: body,
).catchError((_) {});
notifyListeners();
}
Future<void> _fireAndSave({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()));
}
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 _fireAndSave(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 isRouteCompleted(String routeId) {
final state = _states[routeId];
if (state == null) return false;
final route = getRouteById(routeId);
if (route == null) return false;
return state.positionIndex >= route.positions.length - 1;
}
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 — mantén tus bolsas adentro';
case 2: return '🚛 Aprox. 30 min — espera el aviso de 15 min';
case 3: return '⚠️ ¡15 min! Saca tus bolsas a la acera ahora';
case 4: return '🔔 El camión está en tu colonia';
case 5: return '✅ Recogiendo basura en tu zona';
case 6: return '↩️ Regresando al relleno sanitario';
default: return '🏁 Servicio del día finalizado';
}
}
void dismissNotification() { _lastNotification = null; notifyListeners(); }
void dismissRouteNotification(String routeId) {
if (_lastNotification?.routeId == routeId) {
_lastNotification = null;
notifyListeners();
}
}
void clearReviewPrompt(String routeId) {
final state = _states[routeId];
if (state != null) state.reviewPromptSent = false;
notifyListeners();
}
@override
void dispose() {
_globalTimer?.cancel();
_gpsMonitorTimer?.cancel();
super.dispose();
}
}

View File

@@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
class ThemeService extends ChangeNotifier {
ThemeMode _themeMode = ThemeMode.light;
ThemeMode get themeMode => _themeMode;
bool get isDark => _themeMode == ThemeMode.dark;
ThemeService() { _load(); }
Future<void> _load() async {
final p = await SharedPreferences.getInstance();
final isDark = p.getBool('dark_mode') ?? false;
_themeMode = isDark ? ThemeMode.dark : ThemeMode.light;
notifyListeners();
}
Future<void> toggle() async {
_themeMode = _themeMode == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark;
final p = await SharedPreferences.getInstance();
await p.setBool('dark_mode', _themeMode == ThemeMode.dark);
notifyListeners();
}
}

View File

@@ -0,0 +1,233 @@
import 'dart:math' as math;
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);
// Calcular bearing entre dos puntos
double _bearing(LatLng from, LatLng to) {
final lat1 = from.latitude * math.pi / 180;
final lat2 = to.latitude * math.pi / 180;
final dLng = (to.longitude - from.longitude) * math.pi / 180;
final y = math.sin(dLng) * math.cos(lat2);
final x = math.cos(lat1)*math.sin(lat2) - math.sin(lat1)*math.cos(lat2)*math.cos(dLng);
return (math.atan2(y, x) * 180 / math.pi + 360) % 360;
}
// ── Mapa ciudadano ────────────────────────────────────────────────────────
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>[];
// Calcular bearing hacia siguiente punto
double bear = 0;
if (posIdx < route.positions.length - 1) {
bear = _bearing(cur, route.positions[posIdx+1].latLng);
}
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(height: height,
child: FlutterMap(
options: MapOptions(
initialCenter: cur, initialZoom: 14.5,
initialRotation: -bear, // Rotar mapa según dirección del camión
),
children: [
TileLayer(urlTemplate:'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName:'com.example.celaya_limpia'),
// Ruta completa (gris punteada)
PolylineLayer(polylines:[
Polyline(points:all, color:Colors.grey.shade400, strokeWidth:4,
borderColor:Colors.white54, borderStrokeWidth:1),
]),
// Tramo recorrido (guinda)
if (done.isNotEmpty) PolylineLayer(polylines:[
Polyline(points:done, color:AppColors.guindaPrimary, strokeWidth:5,
borderColor:AppColors.guindaDark, borderStrokeWidth:1),
]),
MarkerLayer(markers:[
// Camión con orientación
Marker(point:cur, width:56, height:56,
child:Transform.rotate(
angle: bear * math.pi / 180,
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.navigation:Icons.gps_off,
color:Colors.white,size:26)))),
// Origen
Marker(point:route.positions.first.latLng, width:24, height:24,
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:8))),
// Destino
Marker(point:route.positions.last.latLng, width:28, height:28,
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:14))),
]),
])));
}
}
// ── Mapa Admin (todas las rutas) ──────────────────────────────────────────
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);
double bear = 0;
if (idx < r.positions.length - 1) bear = _bearing(pos, r.positions[idx+1].latLng);
return Marker(point:pos, width:44, height:44,
child:GestureDetector(
onTap:()=>setState(()=>_sel=_sel==r.routeId?null:r.routeId),
child:Transform.rotate(angle: bear*math.pi/180,
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) 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)),
])),
]);
}
}
// ── Mapa conductor (con bearing) ──────────────────────────────────────────
class DriverRouteMap extends StatelessWidget {
final RouteModel route;
final RouteSimulatorService simulator;
const DriverRouteMap({super.key, required this.route, required this.simulator});
@override
Widget build(BuildContext context) {
final idx = simulator.getPositionIndex(route.routeId);
final gps = simulator.isGpsActive(route.routeId);
final positions = route.positions;
final cur = idx < positions.length ? positions[idx].latLng : positions.last.latLng;
double bear = 0;
if (idx < positions.length - 1) bear = _bearing(cur, positions[idx+1].latLng);
final done = idx > 0
? _smooth(positions.take(idx+1).map((p)=>p.latLng).toList())
: <LatLng>[];
final pending = _smooth(positions.skip(idx).map((p)=>p.latLng).toList());
return FlutterMap(
options: MapOptions(initialCenter:cur, initialZoom:15, initialRotation:-bear),
children:[
TileLayer(urlTemplate:'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName:'com.example.celaya_limpia'),
PolylineLayer(polylines:[
Polyline(points:pending, color:Colors.grey.shade400, strokeWidth:5,
borderColor:Colors.white54, borderStrokeWidth:1),
if (done.isNotEmpty)
Polyline(points:done, color:AppColors.moradoConductor, strokeWidth:6,
borderColor:AppColors.moradoConductor.withOpacity(0.4), borderStrokeWidth:2),
]),
MarkerLayer(markers:[
// Waypoints pendientes
...positions.skip(idx+1).take(4).toList().asMap().entries.map((e)=>
Marker(point:e.value.latLng, width:22, height:22,
child:Container(decoration:BoxDecoration(color:Colors.amber,shape:BoxShape.circle,
border:Border.all(color:Colors.white,width:1.5)),
child:Center(child:Text('${idx+2+e.key}',
style:const TextStyle(color:Colors.white,fontSize:9,fontWeight:FontWeight.bold)))))),
// Camión orientado
Marker(point:cur, width:56, height:56,
child:Transform.rotate(angle:bear*math.pi/180,
child:Container(decoration:BoxDecoration(
color:gps?AppColors.moradoConductor:Colors.grey, shape:BoxShape.circle,
border:Border.all(color:Colors.white,width:2.5),
boxShadow:[BoxShadow(color:Colors.black38,blurRadius:8)]),
child:Icon(gps?Icons.navigation:Icons.gps_off,color:Colors.white,size:28)))),
]),
]);
}
}