Primera app funcional

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

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

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

View File

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

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

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

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

@@ -0,0 +1,185 @@
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import '../models/models.dart';
import '../models/route_model.dart';
class DbHelper {
static Database? _db;
static Future<Database> get database async {
_db ??= await _initDb();
return _db!;
}
static Future<Database> _initDb() async {
final path = join(await getDatabasesPath(), 'celaya_v2.db');
return openDatabase(path, version: 1, onCreate: _onCreate);
}
static Future<void> _onCreate(Database db, int v) async {
await db.execute('''CREATE TABLE users(
id INTEGER PRIMARY KEY AUTOINCREMENT, nombre TEXT NOT NULL,
email TEXT UNIQUE NOT NULL, password TEXT NOT NULL, rol TEXT NOT NULL)''');
await db.execute('''CREATE TABLE domicilios(
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL,
calle TEXT NOT NULL, colonia TEXT NOT NULL, route_id TEXT NOT NULL,
horario_estimado TEXT NOT NULL, is_primary INTEGER DEFAULT 1)''');
await db.execute('''CREATE TABLE asignaciones(
id INTEGER PRIMARY KEY AUTOINCREMENT, conductor_id INTEGER NOT NULL,
route_id TEXT NOT NULL, dia_semana TEXT NOT NULL, turno TEXT NOT NULL)''');
await db.execute('''CREATE TABLE route_status(
route_id TEXT PRIMARY KEY, status TEXT NOT NULL,
mensaje TEXT, updated_at TEXT)''');
await db.execute('''CREATE TABLE alertas(
id INTEGER PRIMARY KEY AUTOINCREMENT, tipo TEXT NOT NULL,
route_id TEXT NOT NULL, mensaje TEXT NOT NULL,
fecha TEXT NOT NULL, resuelta INTEGER DEFAULT 0)''');
await db.execute('''CREATE TABLE reportes(
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL,
tipo TEXT NOT NULL, descripcion TEXT NOT NULL, colonia TEXT NOT NULL,
route_id TEXT, fecha TEXT NOT NULL, estado TEXT DEFAULT 'PENDIENTE',
calificacion INTEGER DEFAULT 5)''');
// Seed: admin y conductor demo
await db.insert('users', {'nombre':'Administrador','email':'admin@celaya.gob.mx',
'password':'admin123','rol':'ADMINISTRADOR'});
await db.insert('users', {'nombre':'Juan Conductor','email':'conductor@celaya.gob.mx',
'password':'conductor123','rol':'CONDUCTOR'});
}
// ── USERS ──────────────────────────────────────────────────────────────
static Future<int> insertUser(UserModel u) async =>
(await database).insert('users', u.toMap(), conflictAlgorithm: ConflictAlgorithm.abort);
static Future<UserModel?> getUserByEmail(String email) async {
final res = await (await database).query('users', where:'email=?', whereArgs:[email]);
return res.isEmpty ? null : UserModel.fromMap(res.first);
}
static Future<UserModel?> getUserById(int id) async {
final res = await (await database).query('users', where:'id=?', whereArgs:[id]);
return res.isEmpty ? null : UserModel.fromMap(res.first);
}
static Future<List<UserModel>> getUsersByRol(String rol) async {
final res = await (await database).query('users', where:'rol=?', whereArgs:[rol]);
return res.map((m) => UserModel.fromMap(m)).toList();
}
// ── DOMICILIOS ─────────────────────────────────────────────────────────
static Future<int> insertDomicilio(DomicilioModel d) async =>
(await database).insert('domicilios', d.toMap());
static Future<DomicilioModel?> getPrimaryDomicilio(int userId) async {
final res = await (await database).query('domicilios',
where:'user_id=? AND is_primary=1', whereArgs:[userId]);
return res.isEmpty ? null : DomicilioModel.fromMap(res.first);
}
// ── ASIGNACIONES ───────────────────────────────────────────────────────
static Future<void> upsertAsignacion(AssignmentModel a) async {
final db = await database;
final ex = await db.query('asignaciones',
where:'conductor_id=? AND dia_semana=?',
whereArgs:[a.conductorId, a.diaSemana]);
if (ex.isEmpty) {
await db.insert('asignaciones', a.toMap());
} else {
await db.update('asignaciones', {'route_id':a.routeId,'turno':a.turno},
where:'conductor_id=? AND dia_semana=?',
whereArgs:[a.conductorId, a.diaSemana]);
}
}
static Future<List<AssignmentModel>> getAsignacionesByConductor(int conductorId) async {
final res = await (await database).query('asignaciones',
where:'conductor_id=?', whereArgs:[conductorId]);
return res.map((m) => AssignmentModel.fromMap(m)).toList();
}
static Future<List<AssignmentModel>> getAllAsignaciones() async {
final res = await (await database).query('asignaciones');
return res.map((m) => AssignmentModel.fromMap(m)).toList();
}
// ── ROUTE STATUS ───────────────────────────────────────────────────────
static Future<void> upsertRouteStatus(RouteStatusModel s) async {
final db = await database;
await db.insert('route_status', s.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace);
}
static Future<RouteStatusModel?> getRouteStatus(String routeId) async {
final res = await (await database).query('route_status',
where:'route_id=?', whereArgs:[routeId]);
return res.isEmpty ? null : RouteStatusModel.fromMap(res.first);
}
static Future<List<RouteStatusModel>> getAllRouteStatuses() async {
final res = await (await database).query('route_status');
return res.map((m) => RouteStatusModel.fromMap(m)).toList();
}
// ── ALERTAS ────────────────────────────────────────────────────────────
static Future<int> insertAlerta(AlertaModel a) async =>
(await database).insert('alertas', a.toMap());
static Future<List<AlertaModel>> getAlertas({bool soloNoResueltas = false}) async {
final db = await database;
final res = soloNoResueltas
? await db.query('alertas', where:'resuelta=0', orderBy:'fecha DESC')
: await db.query('alertas', orderBy:'fecha DESC');
return res.map((m) => AlertaModel.fromMap(m)).toList();
}
static Future<void> resolverAlerta(int id) async =>
(await database).update('alertas', {'resuelta':1}, where:'id=?', whereArgs:[id]);
// ── REPORTES ───────────────────────────────────────────────────────────
static Future<int> insertReporte(ReporteModel r) async =>
(await database).insert('reportes', r.toMap());
static Future<List<ReporteModel>> getReportesByUser(int userId) async {
final res = await (await database).query('reportes',
where:'user_id=?', whereArgs:[userId], orderBy:'fecha DESC');
return res.map((m) => ReporteModel.fromMap(m)).toList();
}
static Future<List<ReporteModel>> getAllReportes() async {
final res = await (await database).query('reportes', orderBy:'fecha DESC');
return res.map((m) => ReporteModel.fromMap(m)).toList();
}
static Future<void> updateReporteEstado(int id, String estado) async =>
(await database).update('reportes', {'estado':estado}, where:'id=?', whereArgs:[id]);
// ── REPORTES CON INFO DE USUARIO ──────────────────────────────────────
static Future<List<Map<String, dynamic>>> getReportesConUsuario() async {
final db = await database;
return db.rawQuery('''
SELECT r.*, u.nombre as user_nombre, u.email as user_email
FROM reportes r
LEFT JOIN users u ON r.user_id = u.id
ORDER BY r.fecha DESC
''');
}
// ── INCIDENTES CONDUCTOR ───────────────────────────────────────────────
static Future<List<AlertaModel>> getIncidentesConductor() async {
final res = await (await database).query('alertas',
where: "tipo LIKE 'INCIDENTE_%'", orderBy: 'fecha DESC');
return res.map((m) => AlertaModel.fromMap(m)).toList();
}
// ── DOMICILIOS POR RUTA ────────────────────────────────────────────────
static Future<List<DomicilioModel>> getDomiciliosByRoute(String routeId) async {
final res = await (await database).query('domicilios',
where: 'route_id = ?', whereArgs: [routeId]);
return res.map((m) => DomicilioModel.fromMap(m)).toList();
}
}

View File

@@ -1,121 +1,56 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'core/app_colors.dart';
import 'services/auth_service.dart';
import 'services/route_simulator_service.dart';
import 'screens/splash_screen.dart';
import 'screens/login_screen.dart';
import 'screens/register_screen.dart';
import 'screens/citizen/citizen_home_screen.dart';
import 'screens/driver/driver_home_screen.dart';
import 'screens/admin/admin_dashboard_screen.dart';
void main() {
runApp(const MyApp());
void main() async {
WidgetsFlutterBinding.ensureInitialized();
runApp(const CelayaLimpiaApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
// This is the theme of your application.
//
// TRY THIS: Try running your application with "flutter run". You'll see
// the application has a purple toolbar. Then, without quitting the app,
// try changing the seedColor in the colorScheme below to Colors.green
// and then invoke "hot reload" (save your changes or press the "hot
// reload" button in a Flutter-supported IDE, or press "r" if you used
// the command line to start the app).
//
// Notice that the counter didn't reset back to zero; the application
// state is not lost during the reload. To reset the state, use hot
// restart instead.
//
// This works for code too, not just values: Most code changes can be
// tested with just a hot reload.
colorScheme: .fromSeed(seedColor: Colors.deepPurple),
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
// This widget is the home page of your application. It is stateful, meaning
// that it has a State object (defined below) that contains fields that affect
// how it looks.
// This class is the configuration for the state. It holds the values (in this
// case the title) provided by the parent (in this case the App widget) and
// used by the build method of the State. Fields in a Widget subclass are
// always marked "final".
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
// This call to setState tells the Flutter framework that something has
// changed in this State, which causes it to rerun the build method below
// so that the display can reflect the updated values. If we changed
// _counter without calling setState(), then the build method would not be
// called again, and so nothing would appear to happen.
_counter++;
});
}
class CelayaLimpiaApp extends StatelessWidget {
const CelayaLimpiaApp({super.key});
@override
Widget build(BuildContext context) {
// This method is rerun every time setState is called, for instance as done
// by the _incrementCounter method above.
//
// The Flutter framework has been optimized to make rerunning build methods
// fast, so that you can just rebuild anything that needs updating rather
// than having to individually change instances of widgets.
return Scaffold(
appBar: AppBar(
// TRY THIS: Try changing the color here to a specific color (to
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
// change color while the other colors stay the same.
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
// Here we take the value from the MyHomePage object that was created by
// the App.build method, and use it to set our appbar title.
title: Text(widget.title),
),
body: Center(
// Center is a layout widget. It takes a single child and positions it
// in the middle of the parent.
child: Column(
// Column is also a layout widget. It takes a list of children and
// arranges them vertically. By default, it sizes itself to fit its
// children horizontally, and tries to be as tall as its parent.
//
// Column has various properties to control how it sizes itself and
// how it positions its children. Here we use mainAxisAlignment to
// center the children vertically; the main axis here is the vertical
// axis because Columns are vertical (the cross axis would be
// horizontal).
//
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
// action in the IDE, or press "p" in the console), to see the
// wireframe for each widget.
mainAxisAlignment: .center,
children: [
const Text('You have pushed the button this many times:'),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => AuthService()),
ChangeNotifierProvider(create: (_) => RouteSimulatorService()),
],
child: MaterialApp(
title: 'Celaya Limpia',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: AppColors.guindaPrimary,
primary: AppColors.guindaPrimary,
secondary: AppColors.dorado,
),
inputDecorationTheme: const InputDecorationTheme(
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: AppColors.guindaPrimary, width: 2),
),
],
labelStyle: TextStyle(color: AppColors.guindaPrimary),
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
initialRoute: '/splash',
routes: {
'/splash': (_) => const SplashScreen(),
'/login': (_) => const LoginScreen(),
'/register': (_) => const RegisterScreen(),
'/home': (_) => const CitizenHomeScreen(),
'/driver': (_) => const DriverHomeScreen(),
'/admin': (_) => const AdminDashboardScreen(),
},
),
);
}

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

@@ -0,0 +1,126 @@
// ── USER ──────────────────────────────────────────────────────────────────
class UserModel {
final int? id;
final String nombre;
final String email;
final String password;
final String rol; // CIUDADANO | CONDUCTOR | ADMINISTRADOR
UserModel({this.id, required this.nombre, required this.email,
required this.password, required this.rol});
Map<String, dynamic> toMap() =>
{'id': id, 'nombre': nombre, 'email': email, 'password': password, 'rol': rol};
factory UserModel.fromMap(Map<String, dynamic> m) => UserModel(
id: m['id'], nombre: m['nombre'], email: m['email'],
password: m['password'], rol: m['rol']);
}
// ── DOMICILIO (citizen) ───────────────────────────────────────────────────
class DomicilioModel {
final int? id;
final int userId;
final String calle;
final String colonia;
final String routeId;
final String horarioEstimado;
final bool isPrimary;
DomicilioModel({this.id, required this.userId, required this.calle,
required this.colonia, required this.routeId,
required this.horarioEstimado, this.isPrimary = true});
Map<String, dynamic> toMap() => {'id': id, 'user_id': userId, 'calle': calle,
'colonia': colonia, 'route_id': routeId,
'horario_estimado': horarioEstimado, 'is_primary': isPrimary ? 1 : 0};
factory DomicilioModel.fromMap(Map<String, dynamic> m) => DomicilioModel(
id: m['id'], userId: m['user_id'], calle: m['calle'],
colonia: m['colonia'], routeId: m['route_id'],
horarioEstimado: m['horario_estimado'], isPrimary: m['is_primary'] == 1);
}
// ── ASSIGNMENT (driver schedule) ──────────────────────────────────────────
class AssignmentModel {
final int? id;
final int conductorId;
final String routeId;
final String diaSemana;
final String turno; // MATUTINO | VESPERTINO | NOCTURNO
AssignmentModel({this.id, required this.conductorId, required this.routeId,
required this.diaSemana, required this.turno});
Map<String, dynamic> toMap() => {'id': id, 'conductor_id': conductorId,
'route_id': routeId, 'dia_semana': diaSemana, 'turno': turno};
factory AssignmentModel.fromMap(Map<String, dynamic> m) => AssignmentModel(
id: m['id'], conductorId: m['conductor_id'], routeId: m['route_id'],
diaSemana: m['dia_semana'], turno: m['turno']);
}
// ── ROUTE STATUS ──────────────────────────────────────────────────────────
class RouteStatusModel {
final String routeId;
final String status;
final String? mensaje;
final String updatedAt;
RouteStatusModel({required this.routeId, required this.status,
this.mensaje, required this.updatedAt});
Map<String, dynamic> toMap() => {'route_id': routeId, 'status': status,
'mensaje': mensaje, 'updated_at': updatedAt};
factory RouteStatusModel.fromMap(Map<String, dynamic> m) => RouteStatusModel(
routeId: m['route_id'], status: m['status'],
mensaje: m['mensaje'], updatedAt: m['updated_at']);
}
// ── ALERTA ────────────────────────────────────────────────────────────────
class AlertaModel {
final int? id;
final String tipo; // GPS_PERDIDO | CAMION_DETENIDO | FALLA_MECANICA
final String routeId;
final String mensaje;
final String fecha;
final bool resuelta;
AlertaModel({this.id, required this.tipo, required this.routeId,
required this.mensaje, required this.fecha, this.resuelta = false});
Map<String, dynamic> toMap() => {'id': id, 'tipo': tipo, 'route_id': routeId,
'mensaje': mensaje, 'fecha': fecha, 'resuelta': resuelta ? 1 : 0};
factory AlertaModel.fromMap(Map<String, dynamic> m) => AlertaModel(
id: m['id'], tipo: m['tipo'], routeId: m['route_id'],
mensaje: m['mensaje'], fecha: m['fecha'], resuelta: m['resuelta'] == 1);
}
// ── REPORTE ───────────────────────────────────────────────────────────────
class ReporteModel {
final int? id;
final int userId;
final String tipo;
final String descripcion;
final String colonia;
final String routeId;
final String fecha;
final String estado;
final int calificacion;
ReporteModel({this.id, required this.userId, required this.tipo,
required this.descripcion, required this.colonia, required this.routeId,
required this.fecha, this.estado = 'PENDIENTE', this.calificacion = 5});
Map<String, dynamic> toMap() => {'id': id, 'user_id': userId, 'tipo': tipo,
'descripcion': descripcion, 'colonia': colonia, 'route_id': routeId,
'fecha': fecha, 'estado': estado, 'calificacion': calificacion};
factory ReporteModel.fromMap(Map<String, dynamic> m) => ReporteModel(
id: m['id'], userId: m['user_id'], tipo: m['tipo'],
descripcion: m['descripcion'], colonia: m['colonia'],
routeId: m['route_id'] ?? '', fecha: m['fecha'],
estado: m['estado'], calificacion: m['calificacion'] ?? 5);
}

View File

@@ -0,0 +1,39 @@
import 'package:latlong2/latlong.dart';
class RoutePosition {
final int positionId;
final double lat;
final double lng;
final int speed;
final String timestamp;
RoutePosition({required this.positionId, required this.lat,
required this.lng, required this.speed, required this.timestamp});
LatLng get latLng => LatLng(lat, lng);
}
class RouteModel {
final String routeId;
final String name;
final int truckId;
String status;
final List<RoutePosition> positions;
final String turno; // MATUTINO | VESPERTINO | NOCTURNO
RouteModel({required this.routeId, required this.name,
required this.truckId, required this.status,
required this.positions, this.turno = 'MATUTINO'});
List<LatLng> get polylinePoints =>
positions.map((p) => LatLng(p.lat, p.lng)).toList();
}
class ColonyModel {
final String colonia;
final String routeId;
final String horarioEstimado;
ColonyModel({required this.colonia, required this.routeId,
required this.horarioEstimado});
}

View File

@@ -0,0 +1,786 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/app_colors.dart';
import '../../services/auth_service.dart';
import '../../services/route_simulator_service.dart';
import '../../database/db_helper.dart';
import '../../models/models.dart';
import '../../data/routes_data.dart';
import '../../widgets/route_map_widget.dart';
class AdminDashboardScreen extends StatefulWidget {
const AdminDashboardScreen({super.key});
@override State<AdminDashboardScreen> createState() => _AdminDashboardScreenState();
}
class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
int _tab = 0;
@override
Widget build(BuildContext context) {
final sim = context.watch<RouteSimulatorService>();
final auth = context.watch<AuthService>();
final last = sim.lastNotification;
final tabs = [
_AdminHomeTab(sim:sim, auth:auth),
_AdminMapTab(sim:sim),
_AdminReportesTab(),
_AdminAssignmentsTab(),
_AdminAlertasTab(sim:sim),
];
return Scaffold(
body: Stack(children:[
tabs[_tab],
if (last!=null) Positioned(top:MediaQuery.of(context).padding.top+8,left:0,right:0,
child:_AdminBanner(notif:last,onDismiss:sim.dismissNotification)),
]),
bottomNavigationBar: NavigationBar(
selectedIndex:_tab,
onDestinationSelected:(i)=>setState(()=>_tab=i),
backgroundColor:Colors.white,
indicatorColor:AppColors.verdeAdmin.withOpacity(0.15),
destinations:const[
NavigationDestination(icon:Icon(Icons.dashboard_outlined),
selectedIcon:Icon(Icons.dashboard,color:AppColors.verdeAdmin),label:'Panel'),
NavigationDestination(icon:Icon(Icons.map_outlined),
selectedIcon:Icon(Icons.map,color:AppColors.verdeAdmin),label:'Mapa'),
NavigationDestination(icon:Icon(Icons.report_outlined),
selectedIcon:Icon(Icons.report,color:AppColors.verdeAdmin),label:'Reportes'),
NavigationDestination(icon:Icon(Icons.calendar_month_outlined),
selectedIcon:Icon(Icons.calendar_month,color:AppColors.verdeAdmin),label:'Asignar'),
NavigationDestination(icon:Icon(Icons.warning_outlined),
selectedIcon:Icon(Icons.warning,color:AppColors.verdeAdmin),label:'Alertas'),
],
),
);
}
}
// ── TAB 1: Control de rutas ───────────────────────────────────────────────
class _AdminHomeTab extends StatefulWidget {
final RouteSimulatorService sim; final AuthService auth;
const _AdminHomeTab({required this.sim, required this.auth});
@override State<_AdminHomeTab> createState() => _AdminHomeTabState();
}
class _AdminHomeTabState extends State<_AdminHomeTab> {
List<RouteStatusModel> _statuses = [];
List<AlertaModel> _conductorIncidentes = [];
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final s = await DbHelper.getAllRouteStatuses();
final inc = await DbHelper.getIncidentesConductor();
if (mounted) setState(() { _statuses = s; _conductorIncidentes = inc; });
}
String _getStatus(String rid) {
try { return _statuses.firstWhere((s) => s.routeId == rid).status; }
catch (_) { return RouteStatus.enRuta; }
}
String? _getMensaje(String rid) {
try { return _statuses.firstWhere((s) => s.routeId == rid).mensaje; }
catch (_) { return null; }
}
// Incidentes del conductor asociados a esta ruta (por número)
List<AlertaModel> _getIncidentesPorRuta(String routeId) {
return _conductorIncidentes
.where((i) => !i.resuelta)
.where((i) => i.routeId.contains(routeId) ||
// Si es incidente de conductor sin routeId específico, mostrar en todas
i.routeId.startsWith('CONDUCTOR-'))
.take(2)
.toList();
}
Future<void> _changeStatus(String routeId, String status, String? msg) async {
await DbHelper.upsertRouteStatus(RouteStatusModel(
routeId: routeId, status: status, mensaje: msg,
updatedAt: DateTime.now().toIso8601String()));
if (status == RouteStatus.cancelada || status == RouteStatus.fallaMecanica || status == RouteStatus.retrasada) {
final emoji = status == RouteStatus.cancelada ? ''
: status == RouteStatus.fallaMecanica ? '🔧' : '⏱️';
final titulo = status == RouteStatus.cancelada ? 'Ruta Cancelada'
: status == RouteStatus.fallaMecanica ? 'Falla Mecánica' : 'Servicio con Retraso';
final cuerpo = (msg != null && msg.isNotEmpty)
? '$emoji $msg'
: '$emoji La ruta $routeId ${status == RouteStatus.cancelada ? "ha sido cancelada hoy" : status == RouteStatus.fallaMecanica ? "reportó una falla mecánica" : "presenta un retraso"}. Pendiente reprogramación.';
widget.sim.fireCustomNotification(titulo, cuerpo, routeId,
status == RouteStatus.cancelada ? NotifEvent.routeCancelled : NotifEvent.truckStopped);
await DbHelper.insertAlerta(AlertaModel(
tipo: 'RUTA_$status', routeId: routeId, mensaje: cuerpo,
fecha: DateTime.now().toIso8601String()));
}
await _load();
setState(() {});
}
@override
Widget build(BuildContext context) {
return CustomScrollView(slivers: [
SliverAppBar(pinned: true, backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
title: const Text('Panel Administrador', style: TextStyle(fontWeight: FontWeight.bold)),
actions: [
IconButton(icon: const Icon(Icons.refresh), onPressed: _load),
IconButton(icon: const Icon(Icons.logout),
onPressed: () async { await widget.auth.logout();
if (context.mounted) Navigator.pushReplacementNamed(context, '/login'); }),
],
),
SliverPadding(padding: const EdgeInsets.all(12), sliver: SliverList(delegate: SliverChildListDelegate([
Row(children: [
_Stat('Rutas', '${routesData.length}', Icons.local_shipping, AppColors.verdeAdmin),
const SizedBox(width: 10),
_Stat('Incidentes', '${_conductorIncidentes.where((i)=>!i.resuelta).length}',
Icons.warning, AppColors.naranjaAlerta),
]),
const SizedBox(height: 14),
const Text('Control de Rutas', style: TextStyle(fontWeight: FontWeight.bold,
fontSize: 16, color: AppColors.verdeAdmin)),
const SizedBox(height: 8),
...routesData.map((r) {
final status = _getStatus(r.routeId);
final mensaje = _getMensaje(r.routeId);
final gpsOk = widget.sim.isGpsActive(r.routeId);
final nightIcon = r.turno == 'NOCTURNO' ? '🌙 ' : r.turno == 'VESPERTINO' ? '🌅 ' : '🌄 ';
final incidentes = _getIncidentesPorRuta(r.routeId);
return Card(margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10),
side: BorderSide(color: RouteStatus.color(status).withOpacity(0.4), width: 1.2)),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// Cabecera ruta
ListTile(dense: true,
leading: Container(width: 8, height: 44,
decoration: BoxDecoration(color: RouteStatus.color(status),
borderRadius: BorderRadius.circular(4))),
title: Text('${r.routeId}${r.name}',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700)),
subtitle: Wrap(spacing: 6, children: [
Text(RouteStatus.label(status),
style: TextStyle(fontSize: 11, color: RouteStatus.color(status), fontWeight: FontWeight.w600)),
if (!gpsOk)
const Text('📡 Sin GPS', style: TextStyle(fontSize: 10, color: AppColors.rojoError)),
Text(nightIcon + r.turno, style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
]),
trailing: PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, size: 18),
onSelected: (v) async {
if (v == 'GPS') { widget.sim.simulateGpsLost(r.routeId); return; }
if (v == 'RESTORE') { widget.sim.restoreGps(r.routeId); return; }
String? msg;
if (v == RouteStatus.retrasada) {
final res = await _retrasadaDialog(context);
if (res != null) {
final parts = res.split('|');
final nuevoTurno = parts[0];
final extra = parts.length > 1 ? parts[1] : '';
msg = 'Ruta reprogramada al turno $nuevoTurno. $extra'.trim();
}
} else if ([RouteStatus.cancelada, RouteStatus.fallaMecanica].contains(v)) {
msg = await _inputDialog(context, 'Mensaje / solución para ciudadanos');
}
await _changeStatus(r.routeId, v, msg);
},
itemBuilder: (_) => [
const PopupMenuItem(value: 'EN_RUTA', child: Text('✅ En Ruta — Continúa')),
const PopupMenuItem(value: 'RETRASADA', child: Text('⏱️ Marcar Retrasada')),
const PopupMenuItem(value: 'CANCELADA', child: Text('❌ Cancelar y Notificar')),
const PopupMenuItem(value: 'FALLA_MECANICA', child: Text('🔧 Falla Mecánica')),
const PopupMenuDivider(),
const PopupMenuItem(value: 'GPS', child: Text('📡 Simular GPS Perdido')),
const PopupMenuItem(value: 'RESTORE', child: Text('📶 Restaurar GPS')),
],
),
),
// Mensaje del admin si hay
if (mensaje != null && mensaje.isNotEmpty && status != RouteStatus.enRuta)
Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: RouteStatus.color(status).withOpacity(0.08),
borderRadius: BorderRadius.circular(6),
),
child: Row(children: [
Icon(Icons.message_outlined, size: 13, color: RouteStatus.color(status)),
const SizedBox(width: 6),
Expanded(child: Text('Msg ciudadanos: $mensaje',
style: TextStyle(fontSize: 11, color: RouteStatus.color(status)))),
]),
),
),
// Incidentes de conductor pendientes para esta ruta
if (incidentes.isNotEmpty) ...[
const Divider(height: 1, indent: 14, endIndent: 14),
Padding(
padding: const EdgeInsets.fromLTRB(14, 6, 14, 2),
child: Row(children: [
const Icon(Icons.build, size: 13, color: AppColors.moradoConductor),
const SizedBox(width: 4),
const Text('Incidentes del conductor:',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 11,
color: AppColors.moradoConductor)),
]),
),
...incidentes.map((inc) => Padding(
padding: const EdgeInsets.fromLTRB(14, 2, 14, 2),
child: Row(children: [
Container(width: 6, height: 6,
decoration: const BoxDecoration(color: AppColors.moradoConductor,
shape: BoxShape.circle)),
const SizedBox(width: 6),
Expanded(child: Text(inc.mensaje,
style: const TextStyle(fontSize: 11), maxLines: 1,
overflow: TextOverflow.ellipsis)),
TextButton(
onPressed: () async {
// Mostrar diálogo: ¿qué hacer con este incidente?
final accion = await _incidenteDialog(context, inc.mensaje);
if (accion != null) {
await DbHelper.resolverAlerta(inc.id!);
// Soporta formato RETRASADA:TURNO para reprogramación
String realStatus = accion;
String msg = 'Incidente: ${inc.mensaje.substring(0, inc.mensaje.length.clamp(0, 40))}';
if (accion.startsWith('RETRASADA:')) {
final parts = accion.split(':');
realStatus = 'RETRASADA';
final turno = parts.length > 1 ? parts[1] : 'VESPERTINO';
msg = 'Tu ruta ha sido reprogramada al turno $turno por incidente del conductor. '
'Recibirás notificación cuando el camión esté listo.';
}
await _changeStatus(r.routeId, realStatus, msg);
}
},
style: TextButton.styleFrom(
foregroundColor: AppColors.verdeAdmin,
padding: const EdgeInsets.symmetric(horizontal: 8)),
child: const Text('Actuar', style: TextStyle(fontSize: 10)),
),
]),
)),
const SizedBox(height: 6),
],
]));
}),
const SizedBox(height: 80),
]))),
]);
}
Future<String?> _inputDialog(BuildContext ctx, String hint) async {
final ctrl = TextEditingController();
return showDialog<String>(context: ctx, builder: (_) => AlertDialog(
title: const Text('Mensaje para ciudadanos'),
content: TextField(controller: ctrl, maxLines: 2,
decoration: InputDecoration(hintText: hint, border: const OutlineInputBorder())),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancelar')),
ElevatedButton(onPressed: () => Navigator.pop(ctx, ctrl.text), child: const Text('Enviar')),
]));
}
Future<String?> _retrasadaDialog(BuildContext ctx) async {
String turno = 'VESPERTINO';
final ctrl = TextEditingController();
return showDialog<String>(context: ctx, builder: (dCtx) => StatefulBuilder(
builder: (dCtx, setSt) => AlertDialog(
title: const Text('Reprogramar Ruta'),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('¿A qué turno pasará el camión?',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
const SizedBox(height: 8),
Row(children: [
Expanded(child: RadioListTile<String>(dense: true, value: 'MATUTINO',
groupValue: turno, title: const Text('🌄 Matutino'),
onChanged: (v) => setSt(() => turno = v!))),
Expanded(child: RadioListTile<String>(dense: true, value: 'VESPERTINO',
groupValue: turno, title: const Text('🌅 Vespertino'),
onChanged: (v) => setSt(() => turno = v!))),
]),
const SizedBox(height: 8),
TextField(controller: ctrl, maxLines: 2,
decoration: const InputDecoration(
hintText: 'Mensaje adicional para ciudadanos (opcional)',
border: OutlineInputBorder(), isDense: true)),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(dCtx), child: const Text('Cancelar')),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppColors.naranjaAlerta,
foregroundColor: Colors.white),
onPressed: () => Navigator.pop(dCtx, '$turno|${ctrl.text.trim()}'),
child: const Text('Confirmar')),
])));
}
Future<String?> _incidenteDialog(BuildContext ctx, String incMensaje) async {
String turnoSeleccionado = 'VESPERTINO';
return showDialog<String>(context: ctx, builder: (dialogCtx) => StatefulBuilder(
builder: (dialogCtx, setDialogState) => AlertDialog(
title: const Text('Acción sobre el incidente'),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(incMensaje, style: const TextStyle(fontSize: 12, color: AppColors.grisTexto)),
const Divider(),
const Text('Si decides reprogramar, ¿a qué turno?',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)),
const SizedBox(height: 6),
Row(children: [
Expanded(child: RadioListTile<String>(dense: true, value: 'MATUTINO',
groupValue: turnoSeleccionado, title: const Text('🌄 Matutino', style: TextStyle(fontSize: 12)),
onChanged: (v) => setDialogState(() => turnoSeleccionado = v!))),
Expanded(child: RadioListTile<String>(dense: true, value: 'VESPERTINO',
groupValue: turnoSeleccionado, title: const Text('🌅 Vespertino', style: TextStyle(fontSize: 12)),
onChanged: (v) => setDialogState(() => turnoSeleccionado = v!))),
]),
const SizedBox(height: 4),
const Text('¿Qué decisión tomas?', style: TextStyle(fontWeight: FontWeight.bold)),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(dialogCtx), child: const Text('Cerrar')),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(backgroundColor: AppColors.verdeExito, foregroundColor: Colors.white),
onPressed: () => Navigator.pop(dialogCtx, 'EN_RUTA'),
icon: const Icon(Icons.check, size: 14),
label: const Text('Continúa', style: TextStyle(fontSize: 12))),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(backgroundColor: AppColors.naranjaAlerta, foregroundColor: Colors.white),
onPressed: () => Navigator.pop(dialogCtx, 'RETRASADA:$turnoSeleccionado'),
icon: const Icon(Icons.access_time, size: 14),
label: Text('Retraso→$turnoSeleccionado', style: const TextStyle(fontSize: 11))),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(backgroundColor: AppColors.rojoError, foregroundColor: Colors.white),
onPressed: () => Navigator.pop(dialogCtx, 'CANCELADA'),
icon: const Icon(Icons.cancel, size: 14),
label: const Text('Cancelar', style: TextStyle(fontSize: 12))),
])));
}
}
class _AdminMapTab extends StatelessWidget {
final RouteSimulatorService sim;
const _AdminMapTab({required this.sim});
@override
Widget build(BuildContext context) => Scaffold(
appBar:AppBar(automaticallyImplyLeading:false,
backgroundColor:AppColors.verdeAdmin,foregroundColor:Colors.white,
title:const Text('Mapa — Todas las Rutas'),
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
child:Container(height:4,color:AppColors.dorado))),
body:AdminMapWidget(routes:routesData,simulator:sim));
}
// ── TAB 3: Reportes ciudadanos ────────────────────────────────────────────
class _AdminReportesTab extends StatefulWidget {
@override State<_AdminReportesTab> createState() => _AdminReportesTabState();
}
class _AdminReportesTabState extends State<_AdminReportesTab> {
List<Map<String,dynamic>> _reportes = [];
bool _loading = true;
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final r = await DbHelper.getReportesConUsuario();
if (mounted) setState(() { _reportes=r; _loading=false; });
}
static const _tipos = {
'CAMION_NO_PASO':'🚛 No pasó','RETRASO':'⏱️ Retraso',
'RESIDUOS_NO_RECOGIDOS':'🗑️ No recogidos','OTRO':'📝 Otro',
};
@override
Widget build(BuildContext context) => Scaffold(
appBar:AppBar(automaticallyImplyLeading:false,
backgroundColor:AppColors.verdeAdmin,foregroundColor:Colors.white,
title:Text('Reportes Ciudadanos (${_reportes.length})'),
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
child:Container(height:4,color:AppColors.dorado)),
actions:[IconButton(icon:const Icon(Icons.refresh),onPressed:_load)]),
body:_loading?const Center(child:CircularProgressIndicator())
:_reportes.isEmpty?const Center(child:Text('Sin reportes'))
:ListView.builder(padding:const EdgeInsets.all(12),
itemCount:_reportes.length,
itemBuilder:(_,i){
final r = _reportes[i];
final tipo = r['tipo']??'';
final calif = r['calificacion']??5;
final nombre = r['user_nombre']??'Usuario desconocido';
final email = r['user_email']??'';
final colonia = r['colonia']??'';
final routeId = r['route_id']??'';
final estado = r['estado']??'PENDIENTE';
final id = r['id'] as int?;
return Card(margin:const EdgeInsets.only(bottom:8),
child:Padding(padding:const EdgeInsets.all(12),child:Column(
crossAxisAlignment:CrossAxisAlignment.start, children:[
// Quién reportó
Row(children:[
const Icon(Icons.person,color:AppColors.verdeAdmin,size:14),
const SizedBox(width:4),
Expanded(child:Text('$nombre ($email)',
style:const TextStyle(fontWeight:FontWeight.bold,fontSize:12,color:AppColors.verdeAdmin))),
Container(padding:const EdgeInsets.symmetric(horizontal:6,vertical:2),
decoration:BoxDecoration(color:_estadoColor(estado).withOpacity(0.15),
borderRadius:BorderRadius.circular(10)),
child:Text(estado,style:TextStyle(fontSize:9,color:_estadoColor(estado),
fontWeight:FontWeight.bold))),
]),
const SizedBox(height:4),
Row(children:[
const Icon(Icons.location_city,color:AppColors.grisTexto,size:12),
const SizedBox(width:4),
Text('$colonia$routeId',style:const TextStyle(color:AppColors.grisTexto,fontSize:11)),
]),
const SizedBox(height:6),
Text(_tipos[tipo]??tipo,style:const TextStyle(fontWeight:FontWeight.w600,fontSize:13)),
Text(r['descripcion']??'',style:const TextStyle(fontSize:12,color:AppColors.grisTexto)),
const SizedBox(height:6),
Row(children:[
Text(''*calif,style:const TextStyle(fontSize:11)),
const Spacer(),
PopupMenuButton<String>(
child:Text(estado,style:TextStyle(fontSize:11,color:_estadoColor(estado),
fontWeight:FontWeight.bold,decoration:TextDecoration.underline)),
onSelected:(v)async{
if(id!=null) await DbHelper.updateReporteEstado(id,v);
await _load();
},
itemBuilder:(_)=>['PENDIENTE','EN_REVISION','RESUELTO','DESESTIMADO']
.map((e)=>PopupMenuItem(value:e,child:Text(e))).toList()),
]),
])));
}),
);
Color _estadoColor(String e){
switch(e){case'RESUELTO':return AppColors.verdeExito;
case'EN_REVISION':return AppColors.azulInfo;
case'DESESTIMADO':return AppColors.grisTexto;
default:return AppColors.naranjaAlerta;}
}
}
// ── TAB 4: Asignaciones ───────────────────────────────────────────────────
// ── TAB 4: Asignaciones LMV / MJS ────────────────────────────────────────
class _AdminAssignmentsTab extends StatefulWidget {
@override State<_AdminAssignmentsTab> createState() => _AdminAssignmentsTabState();
}
class _AdminAssignmentsTabState extends State<_AdminAssignmentsTab> {
List<UserModel> _conductores = [];
UserModel? _sel;
List<AssignmentModel> _asigs = [];
// Grupos fijos de días
static const _grupoA = ['LUNES','MIERCOLES','VIERNES'];
static const _grupoB = ['MARTES','JUEVES','SABADO'];
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final c = await DbHelper.getUsersByRol('CONDUCTOR');
if (mounted) setState(() => _conductores = c);
}
Future<void> _loadAsigs(int id) async {
final a = await DbHelper.getAsignacionesByConductor(id);
if (mounted) setState(() => _asigs = a);
}
// Obtener asignación de un grupo (busca cualquier día del grupo)
AssignmentModel? _getGrupo(List<String> dias) {
for (final dia in dias) {
try { return _asigs.firstWhere((a) => a.diaSemana == dia); } catch (_) {}
}
return null;
}
// Guardar asignación para todos los días del grupo
Future<void> _saveGrupo(List<String> dias, String routeId, String turno) async {
for (final dia in dias) {
await DbHelper.upsertAsignacion(AssignmentModel(
conductorId: _sel!.id!, routeId: routeId,
diaSemana: dia, turno: turno));
}
await _loadAsigs(_sel!.id!);
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(automaticallyImplyLeading: false,
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
title: const Text('Asignar Rutas a Conductores'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado))),
body: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(children: [
// Info de esquema
Container(padding: const EdgeInsets.all(10), margin: const EdgeInsets.only(bottom:12),
decoration: BoxDecoration(color: Colors.blue.shade50, borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.shade200)),
child: const Text(
'📅 Cada conductor opera en uno de dos bloques:\n'
' Grupo A — Lunes, Miércoles y Viernes\n'
' Grupo B — Martes, Jueves y Sábado',
style: TextStyle(fontSize: 12, color: AppColors.azulInfo))),
DropdownButtonFormField<UserModel>(
decoration: const InputDecoration(labelText: 'Selecciona conductor',
border: OutlineInputBorder(), filled: true, fillColor: Colors.white),
hint: const Text('Conductor...'),
value: _sel,
items: _conductores.map((c) => DropdownMenuItem(value: c,
child: Text(c.nombre, style: const TextStyle(fontSize: 13)))).toList(),
onChanged: (c) { setState(() => _sel = c); if (c != null) _loadAsigs(c.id!); }),
if (_sel != null) ...[
const SizedBox(height: 20),
// GRUPO A
_GrupoRow(
label: 'Grupo A — Lunes, Miércoles y Viernes',
icon: Icons.wb_sunny_outlined,
color: Colors.blue,
current: _getGrupo(_grupoA),
routeIds: routesData.map((r) => r.routeId).toList(),
onSave: (rid, turno) => _saveGrupo(_grupoA, rid, turno),
),
const SizedBox(height: 12),
// GRUPO B
_GrupoRow(
label: 'Grupo B — Martes, Jueves y Sábado',
icon: Icons.wb_twilight,
color: Colors.deepPurple,
current: _getGrupo(_grupoB),
routeIds: routesData.map((r) => r.routeId).toList(),
onSave: (rid, turno) => _saveGrupo(_grupoB, rid, turno),
),
// Resumen actual
if (_asigs.isNotEmpty) ...[
const SizedBox(height: 20),
const Text('Resumen actual', style: TextStyle(fontWeight: FontWeight.bold,
color: AppColors.verdeAdmin, fontSize: 14)),
const SizedBox(height: 8),
Card(child: Padding(padding: const EdgeInsets.all(12), child: Column(children: [
..._asigs.map((a) => Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(children: [
SizedBox(width: 100, child: Text(AppDias.label(a.diaSemana),
style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 12))),
Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(color: AppColors.verdeAdmin.withOpacity(0.1),
borderRadius: BorderRadius.circular(10)),
child: Text('${a.routeId}${a.turno}',
style: const TextStyle(fontSize: 11, color: AppColors.verdeAdmin))),
]))),
]))),
],
],
])),
);
}
// Fila de asignación por grupo (LMV o MJS)
class _GrupoRow extends StatefulWidget {
final String label;
final IconData icon;
final Color color;
final AssignmentModel? current;
final List<String> routeIds;
final Function(String, String) onSave;
const _GrupoRow({required this.label, required this.icon, required this.color,
required this.current, required this.routeIds, required this.onSave});
@override State<_GrupoRow> createState() => _GrupoRowState();
}
class _GrupoRowState extends State<_GrupoRow> {
String? _route;
String _turno = 'MATUTINO';
@override void initState() {
super.initState();
_route = widget.current?.routeId;
_turno = widget.current?.turno ?? 'MATUTINO';
}
@override
Widget build(BuildContext context) => Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10),
side: BorderSide(color: widget.color.withOpacity(0.3))),
child: Padding(padding: const EdgeInsets.all(14), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(widget.icon, color: widget.color, size: 18),
const SizedBox(width: 8),
Expanded(child: Text(widget.label,
style: TextStyle(fontWeight: FontWeight.bold, color: widget.color, fontSize: 13))),
]),
const SizedBox(height: 12),
Row(children: [
Expanded(child: DropdownButtonFormField<String>(
value: _route,
decoration: const InputDecoration(labelText: 'Ruta',
border: OutlineInputBorder(), isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 10)),
hint: const Text('Sin ruta', style: TextStyle(fontSize: 12)),
items: widget.routeIds.map((r) => DropdownMenuItem(value: r,
child: Text(r, style: const TextStyle(fontSize: 12)))).toList(),
onChanged: (v) => setState(() => _route = v))),
const SizedBox(width: 8),
SizedBox(width: 140, child: DropdownButtonFormField<String>(
value: _turno,
decoration: const InputDecoration(labelText: 'Turno',
border: OutlineInputBorder(), isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 10)),
items: const [
DropdownMenuItem(value:'MATUTINO', child:Text('🌄 Matutino',style:TextStyle(fontSize:12))),
DropdownMenuItem(value:'VESPERTINO',child:Text('🌅 Vespertino',style:TextStyle(fontSize:12))),
DropdownMenuItem(value:'NOCTURNO', child:Text('🌙 Nocturno',style:TextStyle(fontSize:12))),
],
onChanged: (v) => setState(() => _turno = v!))),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _route == null ? null : () => widget.onSave(_route!, _turno),
style: ElevatedButton.styleFrom(
backgroundColor: widget.color, foregroundColor: Colors.white,
minimumSize: const Size(50, 42), padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
child: const Icon(Icons.save, size: 18)),
]),
])));
}
class _AdminAlertasTab extends StatefulWidget {
final RouteSimulatorService sim;
const _AdminAlertasTab({required this.sim});
@override State<_AdminAlertasTab> createState() => _AdminAlertasTabState();
}
class _AdminAlertasTabState extends State<_AdminAlertasTab> {
List<AlertaModel> _alertas = [];
bool _soloActivas = false;
@override void initState(){ super.initState(); _load(); }
Future<void> _load() async {
final a = await DbHelper.getAlertas(soloNoResueltas:_soloActivas);
if (mounted) setState(()=>_alertas=a);
}
IconData _icon(String tipo){
if(tipo.startsWith('INCIDENTE_')) return Icons.build;
switch(tipo){
case'GPS_PERDIDO': return Icons.gps_off;
case'CAMION_DETENIDO': return Icons.warning_amber;
default: return Icons.info;
}
}
Color _color(String tipo){
if(tipo.startsWith('INCIDENTE_')) return AppColors.moradoConductor;
switch(tipo){
case'GPS_PERDIDO': return AppColors.rojoError;
case'CAMION_DETENIDO': return AppColors.naranjaAlerta;
case'RUTA_CANCELADA': return AppColors.rojoError;
default: return AppColors.azulInfo;
}
}
@override
Widget build(BuildContext context) => Scaffold(
appBar:AppBar(automaticallyImplyLeading:false,
backgroundColor:AppColors.verdeAdmin,foregroundColor:Colors.white,
title:Text('Alertas (${_alertas.where((a)=>!a.resuelta).length} activas)'),
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
child:Container(height:4,color:AppColors.dorado)),
actions:[
Switch(value:_soloActivas,onChanged:(v){setState(()=>_soloActivas=v);_load();},
activeColor:AppColors.dorado),
IconButton(icon:const Icon(Icons.refresh),onPressed:_load),
]),
body:_alertas.isEmpty
?Center(child:Column(mainAxisAlignment:MainAxisAlignment.center,children:[
const Icon(Icons.check_circle,color:AppColors.verdeExito,size:48),
const SizedBox(height:8),
Text(_soloActivas?'Sin alertas activas':'Sin alertas registradas',
style:const TextStyle(color:AppColors.grisTexto))]))
:ListView.builder(padding:const EdgeInsets.all(12),
itemCount:_alertas.length,
itemBuilder:(_,i){
final a = _alertas[i];
final esIncidente = a.tipo.startsWith('INCIDENTE_');
return Card(margin:const EdgeInsets.only(bottom:8),
color:a.resuelta?Colors.grey.shade50:null,
child:ListTile(
leading:CircleAvatar(backgroundColor:a.resuelta?Colors.grey:_color(a.tipo),
child:Icon(_icon(a.tipo),color:Colors.white,size:18)),
title:Row(children:[
if(esIncidente) Container(margin:const EdgeInsets.only(right:6),
padding:const EdgeInsets.symmetric(horizontal:6,vertical:2),
decoration:BoxDecoration(color:AppColors.moradoConductor.withOpacity(0.1),
borderRadius:BorderRadius.circular(8)),
child:const Text('CONDUCTOR',style:TextStyle(fontSize:9,color:AppColors.moradoConductor,fontWeight:FontWeight.bold))),
Expanded(child:Text('${a.tipo.replaceAll('_',' ')}${a.routeId}',
style:TextStyle(fontSize:12,fontWeight:FontWeight.bold,
color:a.resuelta?AppColors.grisTexto:AppColors.negroTexto))),
]),
subtitle:Text(a.mensaje,style:const TextStyle(fontSize:11)),
trailing:a.resuelta
?const Icon(Icons.check_circle,color:AppColors.verdeExito,size:20)
:TextButton(
onPressed:()async{ await DbHelper.resolverAlerta(a.id!); await _load(); },
style:TextButton.styleFrom(foregroundColor:AppColors.verdeAdmin),
child:const Text('Resolver',style:TextStyle(fontSize:11))),
));
}),
);
}
// ── Widgets ───────────────────────────────────────────────────────────────
class _Stat extends StatelessWidget {
final String label,value; final IconData icon; final Color color;
const _Stat(this.label,this.value,this.icon,this.color);
@override
Widget build(BuildContext context) => Expanded(child:Card(
child:Padding(padding:const EdgeInsets.all(14),child:Row(children:[
Icon(icon,color:color,size:28),
const SizedBox(width:10),
Column(crossAxisAlignment:CrossAxisAlignment.start,children:[
Text(value,style:TextStyle(fontSize:22,fontWeight:FontWeight.bold,color:color)),
Text(label,style:const TextStyle(fontSize:11,color:AppColors.grisTexto)),
]),
]))));
}
class _AdminBanner extends StatelessWidget {
final AppNotification notif; final VoidCallback onDismiss;
const _AdminBanner({required this.notif,required this.onDismiss});
@override
Widget build(BuildContext context) => Material(color:Colors.transparent,
child:Container(margin:const EdgeInsets.all(10),
decoration:BoxDecoration(
color:notif.event==NotifEvent.routeCancelled?AppColors.rojoError:AppColors.rojoError,
borderRadius:BorderRadius.circular(12),
boxShadow:const[BoxShadow(color:Colors.black26,blurRadius:6)]),
child:Padding(padding:const EdgeInsets.all(12),child:Row(children:[
const Icon(Icons.admin_panel_settings,color:Colors.white,size:22),
const SizedBox(width:8),
Expanded(child:Column(crossAxisAlignment:CrossAxisAlignment.start,mainAxisSize:MainAxisSize.min,children:[
Text(notif.title,style:const TextStyle(color:Colors.white,fontWeight:FontWeight.bold,fontSize:13)),
Text(notif.body,style:const TextStyle(color:Colors.white70,fontSize:11),
maxLines:2,overflow:TextOverflow.ellipsis),
])),
IconButton(icon:const Icon(Icons.close,color:Colors.white,size:18),onPressed:onDismiss),
]))));
}

View File

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

View File

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

View File

@@ -0,0 +1,448 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/app_colors.dart';
import '../../services/auth_service.dart';
import '../../services/route_simulator_service.dart';
import '../../database/db_helper.dart';
import '../../models/models.dart';
import '../../data/routes_data.dart';
import '../../widgets/route_map_widget.dart';
import 'citizen_guia_screen.dart';
import 'citizen_reporte_screen.dart';
class CitizenHomeScreen extends StatefulWidget {
const CitizenHomeScreen({super.key});
@override State<CitizenHomeScreen> createState() => _CitizenHomeScreenState();
}
class _CitizenHomeScreenState extends State<CitizenHomeScreen> {
int _tab = 0;
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthService>();
final sim = context.watch<RouteSimulatorService>();
final dom = auth.primaryDomicilio; // domicilio del ciudadano
final last = dom != null ? sim.getNotificationForRoute(dom.routeId) : null;
final tabs = [
_HomeTab(auth: auth, sim: sim),
const CitizenGuiaScreen(),
const CitizenReporteScreen(),
];
return Scaffold(
backgroundColor: AppColors.grisFondo,
body: Stack(children: [
tabs[_tab],
if (last != null)
Positioned(
top: MediaQuery.of(context).padding.top + 8, left: 0, right: 0,
child: _NotifBanner(notif: last, onDismiss: () => sim.dismissRouteNotification(dom?.routeId ?? '')),
),
]),
bottomNavigationBar: NavigationBar(
selectedIndex: _tab,
onDestinationSelected: (i) => setState(() => _tab = i),
backgroundColor: Colors.white,
indicatorColor: AppColors.guindaPrimary.withOpacity(0.15),
destinations: const [
NavigationDestination(icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home, color: AppColors.guindaPrimary), label: 'Inicio'),
NavigationDestination(icon: Icon(Icons.eco_outlined),
selectedIcon: Icon(Icons.eco, color: AppColors.guindaPrimary), label: 'Guía'),
NavigationDestination(icon: Icon(Icons.report_outlined),
selectedIcon: Icon(Icons.report, color: AppColors.guindaPrimary), label: 'Reportar'),
],
),
);
}
}
// ── Tab principal (StatefulWidget para cargar status de ruta) ─────────────
class _HomeTab extends StatefulWidget {
final AuthService auth;
final RouteSimulatorService sim;
const _HomeTab({required this.auth, required this.sim});
@override State<_HomeTab> createState() => _HomeTabState();
}
class _HomeTabState extends State<_HomeTab> {
RouteStatusModel? _routeStatus;
@override
void initState() {
super.initState();
_loadStatus();
}
Future<void> _loadStatus() async {
final dom = widget.auth.primaryDomicilio;
if (dom == null) return;
final s = await DbHelper.getRouteStatus(dom.routeId);
if (mounted) setState(() => _routeStatus = s);
}
bool get _isRouteProblematic {
final s = _routeStatus?.status ?? RouteStatus.enRuta;
return s == RouteStatus.cancelada ||
s == RouteStatus.fallaMecanica ||
s == RouteStatus.retrasada;
}
@override
Widget build(BuildContext context) {
final dom = widget.auth.primaryDomicilio;
final routeId = dom?.routeId ?? '';
final route = dom != null ? getRouteById(dom.routeId) : null;
final isTruckClose = widget.sim.isTruckClose(routeId);
final status = _routeStatus?.status ?? RouteStatus.enRuta;
return RefreshIndicator(
onRefresh: _loadStatus,
child: CustomScrollView(slivers: [
SliverAppBar(
expandedHeight: 120, pinned: true,
backgroundColor: AppColors.guindaPrimary,
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
flexibleSpace: FlexibleSpaceBar(
background: Container(
color: AppColors.guindaPrimary,
padding: const EdgeInsets.fromLTRB(20, 50, 20, 16),
child: Row(children: [
const Icon(Icons.delete_sweep_rounded, color: AppColors.dorado, size: 30),
const SizedBox(width: 12),
Expanded(child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Hola, ${widget.auth.currentUser?.nombre.split(' ').first ?? ''}',
style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)),
const Text('Celaya Limpia', style: TextStyle(color: AppColors.dorado, fontSize: 12)),
],
)),
IconButton(
icon: const Icon(Icons.logout, color: Colors.white70),
onPressed: () async {
await widget.auth.logout();
if (context.mounted) Navigator.pushReplacementNamed(context, '/login');
},
),
]),
),
),
),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(delegate: SliverChildListDelegate([
// ── Si la ruta tiene problema → mostrar alerta en vez de ETA/mapa
if (_isRouteProblematic) ...[
_RouteStatusBanner(status: _routeStatus!),
const SizedBox(height: 12),
] else ...[
// ETA Card normal
_EtaCard(sim: widget.sim, routeId: routeId, dom: dom, route: route),
const SizedBox(height: 12),
// Mapa solo cuando camión está cerca
if (isTruckClose && route != null) ...[
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.shade300),
),
child: const Row(children: [
Icon(Icons.location_on, color: Colors.orange, size: 18),
SizedBox(width: 6),
Expanded(child: Text('📍 El camión está cerca — mapa activado',
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.orange, fontSize: 12))),
]),
),
const SizedBox(height: 8),
RouteMapWidget(route: route, simulator: widget.sim, height: 220),
const SizedBox(height: 12),
],
],
// Aviso privacidad
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.amber.shade50, borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.amber.shade300),
),
child: const Row(children: [
Icon(Icons.shield_outlined, color: Colors.amber, size: 18),
SizedBox(width: 6),
Expanded(child: Text('🔒 Solo ves la información de tu ruta asignada.',
style: TextStyle(fontSize: 11, color: Colors.black87))),
]),
),
const SizedBox(height: 12),
// Info domicilio
if (dom != null)
Card(child: Padding(
padding: const EdgeInsets.all(14),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Row(children: [
Icon(Icons.location_on, color: AppColors.guindaPrimary, size: 16),
SizedBox(width: 6),
Text('Mi Domicilio', style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)),
]),
const Divider(),
Text(dom.calle, style: const TextStyle(fontSize: 13)),
Text('${dom.colonia}${dom.routeId}',
style: const TextStyle(color: AppColors.grisTexto, fontSize: 12)),
Text(dom.horarioEstimado,
style: const TextStyle(color: AppColors.grisTexto, fontSize: 11)),
]),
)),
// Historial notificaciones
if (widget.sim.history.isNotEmpty) ...[
const SizedBox(height: 12),
Card(child: Padding(
padding: const EdgeInsets.all(14),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('Alertas recientes',
style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)),
const Divider(),
...widget.sim.history.take(4).map((n) {
final color = n.event == NotifEvent.truckProximity
? AppColors.naranjaAlerta
: n.event == NotifEvent.routeCompleted
? AppColors.verdeExito
: n.event == NotifEvent.routeCancelled
? AppColors.rojoError
: AppColors.azulInfo;
final icon = n.event == NotifEvent.truckProximity
? Icons.warning_amber
: n.event == NotifEvent.routeCompleted
? Icons.check_circle
: n.event == NotifEvent.routeCancelled
? Icons.cancel
: Icons.local_shipping;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 6),
Expanded(child: Text(n.title,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500))),
Text(
'${n.timestamp.hour.toString().padLeft(2, '0')}:${n.timestamp.minute.toString().padLeft(2, '0')}',
style: const TextStyle(fontSize: 10, color: AppColors.grisTexto),
),
]),
);
}),
]),
)),
],
const SizedBox(height: 80),
])),
),
]),
);
}
}
// ── Banner de ruta con problema ───────────────────────────────────────────
class _RouteStatusBanner extends StatelessWidget {
final RouteStatusModel status;
const _RouteStatusBanner({required this.status});
@override
Widget build(BuildContext context) {
final isCancelled = status.status == RouteStatus.cancelada;
final isFalla = status.status == RouteStatus.fallaMecanica;
final isRetrasada = status.status == RouteStatus.retrasada;
final color = isCancelled ? AppColors.rojoError
: isFalla ? Colors.red.shade800
: AppColors.naranjaAlerta;
final icon = isCancelled ? Icons.cancel
: isFalla ? Icons.build
: Icons.access_time;
final titulo = isCancelled ? '❌ Ruta Cancelada Hoy'
: isFalla ? '🔧 Falla Mecánica en Servicio'
: '⏱️ Servicio con Retraso';
final descripcion = isCancelled
? 'El servicio de recolección de tu colonia no se realizará hoy. Favor de guardar tus residuos para la próxima jornada.'
: isFalla
? 'El camión asignado a tu sector presentó una falla mecánica. El Ayuntamiento está atendiendo la situación.'
: 'El camión de tu sector presenta un retraso en su recorrido. El servicio se realizará, pero con demora.';
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// Alerta principal
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(12),
boxShadow: [BoxShadow(color: color.withOpacity(0.4), blurRadius: 8, offset: const Offset(0, 4))],
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(icon, color: Colors.white, size: 26),
const SizedBox(width: 10),
Expanded(child: Text(titulo,
style: const TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold))),
]),
const SizedBox(height: 10),
Text(descripcion, style: const TextStyle(color: Colors.white, fontSize: 13, height: 1.4)),
]),
),
// Mensaje del administrador (posible solución)
if (status.mensaje != null && status.mensaje!.isNotEmpty) ...[
const SizedBox(height: 10),
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withOpacity(0.4)),
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(Icons.admin_panel_settings, color: color, size: 16),
const SizedBox(width: 6),
Text('Mensaje del Ayuntamiento',
style: TextStyle(fontWeight: FontWeight.bold, color: color, fontSize: 13)),
]),
const SizedBox(height: 6),
Text(status.mensaje!,
style: const TextStyle(fontSize: 13, color: AppColors.negroTexto, height: 1.4)),
]),
),
],
// Consejo ciudadano
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('💡 Recomendaciones:',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: AppColors.grisTexto)),
const SizedBox(height: 4),
if (isCancelled)
const Text('• Guarda tus bolsas en un lugar cerrado\n'
'• No dejes residuos en la acera\n'
'• Revisa la app mañana para el horario actualizado',
style: TextStyle(fontSize: 12, color: AppColors.grisTexto)),
if (isFalla)
const Text('• Espera confirmación del Ayuntamiento\n'
'• Puede enviarse una unidad de reemplazo\n'
'• Revisa las alertas en esta pantalla',
style: TextStyle(fontSize: 12, color: AppColors.grisTexto)),
if (isRetrasada)
const Text('• Tu basura será recogida hoy, con demora\n'
'• Puedes sacar tus bolsas cuando recibas la alerta\n'
'• Recibirás notificación cuando el camión se acerque',
style: TextStyle(fontSize: 12, color: AppColors.grisTexto)),
]),
),
]);
}
}
// ── ETA Card ──────────────────────────────────────────────────────────────
class _EtaCard extends StatelessWidget {
final RouteSimulatorService sim;
final String routeId;
final dom; final route;
const _EtaCard({required this.sim, required this.routeId, required this.dom, required this.route});
@override
Widget build(BuildContext context) => Container(
decoration: BoxDecoration(
gradient: const LinearGradient(colors: [AppColors.guindaPrimary, AppColors.guindaDark],
begin: Alignment.topLeft, end: Alignment.bottomRight),
borderRadius: BorderRadius.circular(14),
boxShadow: [BoxShadow(color: AppColors.guindaDark.withOpacity(0.4),
blurRadius: 8, offset: const Offset(0, 4))],
),
padding: const EdgeInsets.all(18),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
const Icon(Icons.local_shipping, color: AppColors.dorado, size: 22),
const SizedBox(width: 8),
Expanded(child: Text(route?.name ?? 'Ruta asignada',
style: const TextStyle(color: AppColors.dorado, fontSize: 13, fontWeight: FontWeight.w600))),
]),
const SizedBox(height: 8),
Text(sim.getEtaText(routeId),
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
if (dom != null)
Text('${dom.horarioEstimado}',
style: const TextStyle(color: Colors.white60, fontSize: 11)),
const SizedBox(height: 10),
LinearProgressIndicator(
value: route != null
? (sim.getPositionIndex(routeId) + 1) / route.positions.length : 0,
backgroundColor: Colors.white24,
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.dorado),
),
]),
);
}
// ── Banner notificación ───────────────────────────────────────────────────
class _NotifBanner extends StatelessWidget {
final AppNotification notif; final VoidCallback onDismiss;
const _NotifBanner({required this.notif, required this.onDismiss});
@override
Widget build(BuildContext context) {
final color = notif.event == NotifEvent.truckProximity
? AppColors.naranjaAlerta
: notif.event == NotifEvent.routeCompleted
? AppColors.verdeExito
: notif.event == NotifEvent.routeCancelled
? AppColors.rojoError
: notif.event == NotifEvent.gpsLost
? Colors.red.shade800
: AppColors.azulInfo;
return Material(
color: Colors.transparent,
child: Container(
margin: const EdgeInsets.all(12),
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(12),
boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 8, offset: Offset(0, 4))]),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(children: [
const Icon(Icons.notifications_active, color: Colors.white, size: 24),
const SizedBox(width: 10),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, children: [
Text(notif.title, style: const TextStyle(color: Colors.white,
fontWeight: FontWeight.bold, fontSize: 13)),
Text(notif.body, style: const TextStyle(color: Colors.white70, fontSize: 11),
maxLines: 2, overflow: TextOverflow.ellipsis),
])),
IconButton(icon: const Icon(Icons.close, color: Colors.white, size: 18),
onPressed: onDismiss),
]),
),
),
);
}
}

View File

@@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/app_colors.dart';
import '../../database/db_helper.dart';
import '../../models/models.dart';
import '../../services/auth_service.dart';
class CitizenReporteScreen extends StatefulWidget {
const CitizenReporteScreen({super.key});
@override State<CitizenReporteScreen> createState() => _CitizenReporteScreenState();
}
class _CitizenReporteScreenState extends State<CitizenReporteScreen> {
String _tipo = 'CAMION_NO_PASO';
final _desc = TextEditingController();
int _calif = 5;
bool _loading = false, _sent = false;
List<ReporteModel> _reportes = [];
static const _tipos = {
'CAMION_NO_PASO':'🚛 El camión no pasó',
'RETRASO':'⏱️ Retraso significativo',
'RESIDUOS_NO_RECOGIDOS':'🗑️ Residuos no recogidos',
'OTRO':'📝 Otro motivo',
};
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final auth = context.read<AuthService>();
if (auth.currentUser == null) return;
final r = await DbHelper.getReportesByUser(auth.currentUser!.id!);
if (mounted) setState(() => _reportes = r);
}
Future<void> _send() async {
final auth = context.read<AuthService>();
if (auth.currentUser == null || _desc.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Describe el problema'), backgroundColor: AppColors.rojoError)); return;
}
setState(() => _loading = true);
await DbHelper.insertReporte(ReporteModel(
userId: auth.currentUser!.id!, tipo: _tipo, descripcion: _desc.text.trim(),
colonia: auth.primaryDomicilio?.colonia ?? '',
routeId: auth.primaryDomicilio?.routeId ?? '',
fecha: DateTime.now().toIso8601String(), calificacion: _calif,
));
await _load();
if (!mounted) return;
setState(() { _loading = false; _sent = true; _desc.clear(); });
await Future.delayed(const Duration(seconds: 2));
if (mounted) setState(() => _sent = false);
}
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(automaticallyImplyLeading: false,
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
title: const Text('Reportar Incidencia'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado))),
body: _sent ? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
const Icon(Icons.check_circle, color: AppColors.verdeExito, size: 64),
const SizedBox(height: 12),
const Text('¡Reporte enviado!', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: AppColors.verdeExito)),
const Text('El Ayuntamiento lo revisará pronto.', style: TextStyle(color: AppColors.grisTexto)),
])) : SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(children: [
Card(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(padding: const EdgeInsets.all(16), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('Nueva Incidencia', style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.guindaPrimary, fontSize: 16)),
const SizedBox(height: 12),
const Text('Tipo:', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
..._tipos.entries.map((e) => RadioListTile<String>(dense: true, value: e.key,
groupValue: _tipo, title: Text(e.value, style: const TextStyle(fontSize: 13)),
activeColor: AppColors.guindaPrimary,
onChanged: (v) => setState(() => _tipo = v!))),
const SizedBox(height: 8),
DropdownButtonFormField<int>(value: _calif,
decoration: const InputDecoration(labelText: 'Calificación', border: OutlineInputBorder()),
items: [5,4,3,2,1].map((n) => DropdownMenuItem(value: n,
child: Text(['⭐⭐⭐⭐⭐ Excelente','⭐⭐⭐⭐ Bueno','⭐⭐⭐ Regular','⭐⭐ Malo','⭐ Muy malo'][5-n]))).toList(),
onChanged: (v) => setState(() => _calif = v!)),
const SizedBox(height: 10),
TextField(controller: _desc, maxLines: 3,
decoration: const InputDecoration(hintText: 'Describe el problema...',
border: OutlineInputBorder(), filled: true, fillColor: Colors.white)),
const SizedBox(height: 14),
SizedBox(width: double.infinity, height: 48,
child: ElevatedButton.icon(onPressed: _loading ? null : _send,
style: ElevatedButton.styleFrom(backgroundColor: AppColors.guindaPrimary,
foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
icon: _loading ? const SizedBox(width: 18, height: 18,
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) : const Icon(Icons.send),
label: const Text('ENVIAR REPORTE', style: TextStyle(fontWeight: FontWeight.bold)))),
]))),
if (_reportes.isNotEmpty) ...[
const SizedBox(height: 16),
const Align(alignment: Alignment.centerLeft,
child: Text('Mis Reportes', style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.guindaPrimary, fontSize: 15))),
const SizedBox(height: 8),
..._reportes.map((r) => Card(margin: const EdgeInsets.only(bottom: 6),
child: ListTile(dense: true,
leading: const CircleAvatar(backgroundColor: AppColors.guindaPrimary, radius: 16,
child: Icon(Icons.report, color: Colors.white, size: 16)),
title: Text(_tipos[r.tipo] ?? r.tipo, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)),
subtitle: Text(r.descripcion, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11)),
trailing: Container(padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(color: AppColors.naranjaAlerta.withOpacity(0.15), borderRadius: BorderRadius.circular(10)),
child: Text(r.estado, style: const TextStyle(fontSize: 9, color: AppColors.naranjaAlerta, fontWeight: FontWeight.bold)))))),
],
])),
);
@override void dispose() { _desc.dispose(); super.dispose(); }
}

View File

@@ -0,0 +1,456 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/app_colors.dart';
import '../../services/auth_service.dart';
import '../../services/route_simulator_service.dart';
import '../../database/db_helper.dart';
import '../../models/models.dart';
import '../../data/routes_data.dart';
import '../../widgets/route_map_widget.dart';
class DriverHomeScreen extends StatefulWidget {
const DriverHomeScreen({super.key});
@override State<DriverHomeScreen> createState() => _DriverHomeScreenState();
}
class _DriverHomeScreenState extends State<DriverHomeScreen> {
int _tab = 0;
List<AssignmentModel> _assignments = [];
String? _todayRouteId;
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final auth = context.read<AuthService>();
if (auth.currentUser == null) return;
final list = await DbHelper.getAsignacionesByConductor(auth.currentUser!.id!);
final today = _todayDia();
setState(() {
_assignments = list;
final match = list.where((a) => a.diaSemana == today);
_todayRouteId = match.isNotEmpty ? match.first.routeId : null;
});
if (_todayRouteId != null) {
context.read<RouteSimulatorService>().startRoute(_todayRouteId!);
}
}
String _todayDia() {
const d = ['','LUNES','MARTES','MIERCOLES','JUEVES','VIERNES','SABADO','DOMINGO'];
return d[DateTime.now().weekday];
}
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthService>();
final sim = context.watch<RouteSimulatorService>();
final route = _todayRouteId != null ? getRouteById(_todayRouteId!) : null;
// Solo notificaciones de la ruta actual del conductor
final lastNotif = _todayRouteId != null
? sim.getNotificationForRoute(_todayRouteId!) : null;
final tabs = [
_DriverMainTab(auth:auth, sim:sim, route:route,
assignments:_assignments, todayRouteId:_todayRouteId, onRefresh:_load),
if (route != null) _DriverMapTab(route:route, sim:sim)
else const Center(child:Text('Sin ruta hoy')),
_DriverReportesTab(conductorId:auth.currentUser?.id, todayRouteId:_todayRouteId),
];
return Scaffold(
body: Stack(children:[
tabs[_tab],
if (lastNotif != null)
Positioned(top:MediaQuery.of(context).padding.top+8, left:0, right:0,
child:_NotifBanner(notif:lastNotif,
onDismiss:()=>sim.dismissRouteNotification(_todayRouteId??''))),
]),
bottomNavigationBar: NavigationBar(
selectedIndex: _tab,
onDestinationSelected: (i) => setState(()=>_tab=i),
backgroundColor: Colors.white,
indicatorColor: AppColors.moradoConductor.withOpacity(0.15),
destinations: const [
NavigationDestination(icon:Icon(Icons.dashboard_outlined),
selectedIcon:Icon(Icons.dashboard,color:AppColors.moradoConductor),label:'Mi Ruta'),
NavigationDestination(icon:Icon(Icons.map_outlined),
selectedIcon:Icon(Icons.map,color:AppColors.moradoConductor),label:'Mapa'),
NavigationDestination(icon:Icon(Icons.report_problem_outlined),
selectedIcon:Icon(Icons.report_problem,color:AppColors.moradoConductor),label:'Incidente'),
],
),
);
}
}
// ── Tab principal ─────────────────────────────────────────────────────────
class _DriverMainTab extends StatefulWidget {
final AuthService auth; final RouteSimulatorService sim;
final route; final assignments; final todayRouteId; final VoidCallback onRefresh;
const _DriverMainTab({required this.auth, required this.sim, required this.route,
required this.assignments, required this.todayRouteId, required this.onRefresh});
@override State<_DriverMainTab> createState() => _DriverMainTabState();
}
class _DriverMainTabState extends State<_DriverMainTab> {
List<ReporteModel> _ciudadanoReportes = [];
@override void initState() { super.initState(); _loadReportes(); }
Future<void> _loadReportes() async {
if (widget.todayRouteId == null) return;
final all = await DbHelper.getAllReportes();
final filtered = all.where((r) => r.routeId == widget.todayRouteId).toList();
if (mounted) setState(() => _ciudadanoReportes = filtered.take(5).toList());
}
@override
Widget build(BuildContext context) {
final posIdx = widget.todayRouteId != null
? widget.sim.getPositionIndex(widget.todayRouteId!) : 0;
final gpsOk = widget.todayRouteId != null
? widget.sim.isGpsActive(widget.todayRouteId!) : true;
return CustomScrollView(slivers:[
SliverAppBar(pinned:true, backgroundColor:AppColors.moradoConductor, foregroundColor:Colors.white,
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
child:Container(height:4,color:AppColors.dorado)),
title:Text('Conductor: ${widget.auth.currentUser?.nombre.split(' ').first ?? ''}',
style:const TextStyle(fontSize:16,fontWeight:FontWeight.bold)),
actions:[IconButton(icon:const Icon(Icons.logout),
onPressed:()async{ await widget.auth.logout();
if(context.mounted) Navigator.pushReplacementNamed(context,'/login');})]),
SliverPadding(padding:const EdgeInsets.all(14),sliver:SliverList(delegate:SliverChildListDelegate([
// Ruta de hoy
Card(color:AppColors.moradoConductor.withOpacity(0.08),
shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(12),
side:BorderSide(color:AppColors.moradoConductor.withOpacity(0.3))),
child:Padding(padding:const EdgeInsets.all(14),child:Column(
crossAxisAlignment:CrossAxisAlignment.start, children:[
Row(children:[
const Icon(Icons.today,color:AppColors.moradoConductor),
const SizedBox(width:8),
Text('Hoy — ${_todayLabel()}',
style:const TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor,fontSize:15)),
]),
const Divider(),
if (widget.route != null)...[
Text(widget.route.name,style:const TextStyle(fontWeight:FontWeight.bold,fontSize:14)),
Text('Camión ${widget.route.truckId} • Turno: ${widget.route.turno}',
style:const TextStyle(color:AppColors.grisTexto,fontSize:12)),
const SizedBox(height:8),
Row(children:[
Icon(gpsOk?Icons.gps_fixed:Icons.gps_off,
color:gpsOk?AppColors.verdeExito:AppColors.rojoError,size:16),
const SizedBox(width:4),
Text(gpsOk?'GPS Activo':'⚠️ GPS Desactivado',
style:TextStyle(color:gpsOk?AppColors.verdeExito:AppColors.rojoError,
fontWeight:FontWeight.bold,fontSize:12)),
const Spacer(),
Text('Posición ${posIdx+1}/8',style:const TextStyle(color:AppColors.grisTexto,fontSize:12)),
]),
const SizedBox(height:8),
LinearProgressIndicator(value:(posIdx+1)/8,
backgroundColor:Colors.grey.shade300,
valueColor:const AlwaysStoppedAnimation<Color>(AppColors.moradoConductor)),
const SizedBox(height:6),
Text(widget.sim.getEtaText(widget.todayRouteId??''),
style:const TextStyle(fontSize:13,fontWeight:FontWeight.w500)),
] else
const Text('⚠️ Sin ruta asignada hoy.',style:TextStyle(color:AppColors.rojoError)),
]))),
const SizedBox(height:10),
// Instrucciones
Card(child:Padding(padding:const EdgeInsets.all(12),child:Column(
crossAxisAlignment:CrossAxisAlignment.start, children:[
const Text('📋 Instrucciones de Ruta',
style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor)),
const Divider(),
const Text('• Sigue la ruta asignada sin desviaciones\n'
'• Mantén el GPS activo en todo momento\n'
'• Reporta incidentes desde "Incidente"\n'
'• Si hay problema, el admin decidirá si se cancela o retrasa',
style:TextStyle(fontSize:12,color:AppColors.grisTexto)),
]))),
const SizedBox(height:10),
// Reportes ciudadanos de SU ruta
if (_ciudadanoReportes.isNotEmpty) ...[
Card(color:Colors.orange.shade50,
shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(10),
side:BorderSide(color:Colors.orange.shade200)),
child:Padding(padding:const EdgeInsets.all(12),child:Column(
crossAxisAlignment:CrossAxisAlignment.start, children:[
const Row(children:[
Icon(Icons.people,color:AppColors.naranjaAlerta,size:16),
SizedBox(width:6),
Text('Reportes de tu ruta hoy',
style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.naranjaAlerta,fontSize:13)),
]),
const Divider(),
..._ciudadanoReportes.map((r)=>Padding(
padding:const EdgeInsets.symmetric(vertical:3),
child:Row(children:[
const Icon(Icons.person_outline,size:12,color:AppColors.grisTexto),
const SizedBox(width:4),
Expanded(child:Text(r.descripcion,style:const TextStyle(fontSize:11),
maxLines:1,overflow:TextOverflow.ellipsis)),
]))),
]))),
const SizedBox(height:10),
],
// Horario LMV / MJS
Card(child:Padding(padding:const EdgeInsets.all(12),child:Column(
crossAxisAlignment:CrossAxisAlignment.start, children:[
const Text('Mi Horario',
style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor)),
const Divider(),
if (widget.assignments.isEmpty)
const Text('Sin asignaciones. Contacta al administrador.',
style:TextStyle(color:AppColors.grisTexto,fontSize:12))
else ...[
_scheduleGroup(widget.assignments,'LUNES','MIERCOLES','VIERNES',
'Lunes, Miércoles y Viernes'),
const SizedBox(height:8),
_scheduleGroup(widget.assignments,'MARTES','JUEVES','SABADO',
'Martes, Jueves y Sábado'),
],
]))),
const SizedBox(height:80),
]))),
]);
}
Widget _scheduleGroup(List<AssignmentModel> all, String d1, String d2, String d3, String label) {
AssignmentModel? found;
for (final dia in [d1,d2,d3]) {
try { found = all.firstWhere((a)=>a.diaSemana==dia); break; } catch(_){}
}
return Container(padding:const EdgeInsets.all(10),
decoration:BoxDecoration(color:AppColors.moradoConductor.withOpacity(0.06),
borderRadius:BorderRadius.circular(8),
border:Border.all(color:AppColors.moradoConductor.withOpacity(0.2))),
child:Row(children:[
const Icon(Icons.calendar_today,size:14,color:AppColors.moradoConductor),
const SizedBox(width:6),
Expanded(child:Text(label,style:const TextStyle(fontWeight:FontWeight.w600,fontSize:12))),
if (found!=null)
Container(padding:const EdgeInsets.symmetric(horizontal:8,vertical:3),
decoration:BoxDecoration(color:AppColors.moradoConductor,borderRadius:BorderRadius.circular(8)),
child:Text('${found.routeId}${found.turno}',
style:const TextStyle(fontSize:11,color:Colors.white,fontWeight:FontWeight.bold)))
else
const Text('Sin asignar',style:TextStyle(fontSize:11,color:AppColors.grisTexto)),
]));
}
String _todayLabel() {
const d=['','Lunes','Martes','Miércoles','Jueves','Viernes','Sábado','Domingo'];
return d[DateTime.now().weekday];
}
}
// ── Tab mapa ──────────────────────────────────────────────────────────────
class _DriverMapTab extends StatelessWidget {
final route; final sim;
const _DriverMapTab({required this.route, required this.sim});
@override
Widget build(BuildContext context) => Scaffold(
appBar:AppBar(automaticallyImplyLeading:false,
backgroundColor:AppColors.moradoConductor,foregroundColor:Colors.white,
title:Text(route.name,style:const TextStyle(fontSize:13)),
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
child:Container(height:4,color:AppColors.dorado))),
body:RouteMapWidget(route:route,simulator:sim,
height:MediaQuery.of(context).size.height,showFullRoute:true));
}
// ── Tab reporte incidente — usa routeId actual ────────────────────────────
class _DriverReportesTab extends StatefulWidget {
final int? conductorId;
final String? todayRouteId; // Ruta actual del conductor
const _DriverReportesTab({required this.conductorId, required this.todayRouteId});
@override State<_DriverReportesTab> createState() => _DriverReportesTabState();
}
class _DriverReportesTabState extends State<_DriverReportesTab> {
String _tipo = 'INCIDENTE_LLANTA';
final _desc = TextEditingController();
bool _loading = false, _sent = false;
List<AlertaModel> _misIncidentes = [];
static const _tipos = {
'INCIDENTE_LLANTA': '🔧 Llanta ponchada',
'INCIDENTE_MECANICA': '🔥 Falla mecánica',
'INCIDENTE_ACCIDENTE': '🚑 Accidente',
'INCIDENTE_CAMINO': '🚧 Camino bloqueado',
'INCIDENTE_COMBUSTIBLE':'⛽ Sin combustible',
'INCIDENTE_OTRO': '📝 Otro',
};
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final all = await DbHelper.getAlertas();
// Solo incidentes de la ruta actual del conductor
final mine = all.where((a) =>
a.tipo.startsWith('INCIDENTE_') &&
a.routeId == (widget.todayRouteId ?? '')).toList();
if (mounted) setState(() => _misIncidentes = mine);
}
Future<void> _enviar() async {
if (widget.todayRouteId == null) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content:Text('No tienes ruta asignada hoy'),
backgroundColor:AppColors.rojoError)); return;
}
if (_desc.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content:Text('Describe el incidente'),backgroundColor:AppColors.rojoError)); return;
}
setState(()=>_loading=true);
// Guardar el incidente asociado a la RUTA ACTUAL
await DbHelper.insertAlerta(AlertaModel(
tipo: _tipo,
routeId: widget.todayRouteId!, // ← ID de la ruta actual, no del conductor
mensaje: '${_tipos[_tipo]}: ${_desc.text.trim()}',
fecha: DateTime.now().toIso8601String(),
));
await _load();
if (!mounted) return;
setState(() { _loading=false; _sent=true; });
_desc.clear();
await Future.delayed(const Duration(seconds:2));
if (mounted) setState(()=>_sent=false);
}
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor:AppColors.grisFondo,
appBar:AppBar(automaticallyImplyLeading:false,
backgroundColor:AppColors.moradoConductor,foregroundColor:Colors.white,
title:const Text('Reportar Incidente'),
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
child:Container(height:4,color:AppColors.dorado))),
body: _sent
? const Center(child:Column(mainAxisAlignment:MainAxisAlignment.center,children:[
Icon(Icons.check_circle,color:AppColors.verdeExito,size:64),
SizedBox(height:12),
Text('¡Incidente reportado!',style:TextStyle(fontSize:18,fontWeight:FontWeight.bold,color:AppColors.verdeExito)),
Text('El administrador será notificado.',style:TextStyle(color:AppColors.grisTexto)),
]))
: SingleChildScrollView(padding:const EdgeInsets.all(16),child:Column(children:[
// Info ruta actual
if (widget.todayRouteId != null)
Container(margin:const EdgeInsets.only(bottom:12),
padding:const EdgeInsets.all(10),
decoration:BoxDecoration(color:AppColors.moradoConductor.withOpacity(0.08),
borderRadius:BorderRadius.circular(8),
border:Border.all(color:AppColors.moradoConductor.withOpacity(0.3))),
child:Row(children:[
const Icon(Icons.route,color:AppColors.moradoConductor,size:16),
const SizedBox(width:6),
Text('Incidente en: ${widget.todayRouteId}',
style:const TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor,fontSize:13)),
]))
else
Container(margin:const EdgeInsets.only(bottom:12),
padding:const EdgeInsets.all(10),
decoration:BoxDecoration(color:Colors.orange.shade50,borderRadius:BorderRadius.circular(8)),
child:const Text('⚠️ No tienes ruta asignada hoy',
style:TextStyle(color:AppColors.naranjaAlerta,fontWeight:FontWeight.bold))),
Card(shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(12)),
child:Padding(padding:const EdgeInsets.all(16),child:Column(
crossAxisAlignment:CrossAxisAlignment.start, children:[
const Text('Tipo de incidente',
style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor,fontSize:15)),
const SizedBox(height:8),
..._tipos.entries.map((e)=>RadioListTile<String>(dense:true,
value:e.key,groupValue:_tipo,
title:Text(e.value,style:const TextStyle(fontSize:13)),
activeColor:AppColors.moradoConductor,
onChanged:(v)=>setState(()=>_tipo=v!))),
const SizedBox(height:10),
const Text('Descripción',style:TextStyle(fontWeight:FontWeight.w600,fontSize:13)),
const SizedBox(height:6),
TextField(controller:_desc,maxLines:3,
decoration:const InputDecoration(hintText:'Describe qué pasó...',
border:OutlineInputBorder(),filled:true,fillColor:Colors.white)),
const SizedBox(height:12),
Container(padding:const EdgeInsets.all(10),
decoration:BoxDecoration(color:Colors.orange.shade50,
borderRadius:BorderRadius.circular(8),
border:Border.all(color:Colors.orange.shade200)),
child:const Text(
'⚠️ El administrador verá este incidente en tu ruta actual '
'y decidirá si continúa, se retrasa o se cancela.',
style:TextStyle(fontSize:11,color:Colors.black87))),
const SizedBox(height:14),
SizedBox(width:double.infinity,height:48,
child:ElevatedButton.icon(
onPressed:(_loading||widget.todayRouteId==null)?null:_enviar,
style:ElevatedButton.styleFrom(backgroundColor:AppColors.moradoConductor,
foregroundColor:Colors.white,
shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(8))),
icon:_loading?const SizedBox(width:18,height:18,
child:CircularProgressIndicator(color:Colors.white,strokeWidth:2))
:const Icon(Icons.send),
label:const Text('ENVIAR INCIDENTE',style:TextStyle(fontWeight:FontWeight.bold)))),
]))),
if (_misIncidentes.isNotEmpty)...[
const SizedBox(height:16),
const Align(alignment:Alignment.centerLeft,
child:Text('Mis incidentes de hoy',
style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor,fontSize:14))),
const SizedBox(height:8),
..._misIncidentes.take(5).map((a)=>Card(margin:const EdgeInsets.only(bottom:6),
child:ListTile(dense:true,
leading:CircleAvatar(backgroundColor:AppColors.moradoConductor,radius:16,
child:const Icon(Icons.warning,color:Colors.white,size:14)),
title:Text(_tipos[a.tipo]??a.tipo,
style:const TextStyle(fontSize:12,fontWeight:FontWeight.w600)),
subtitle:Text(a.mensaje,maxLines:1,overflow:TextOverflow.ellipsis,
style:const TextStyle(fontSize:11)),
trailing:Icon(a.resuelta?Icons.check_circle:Icons.pending,
color:a.resuelta?AppColors.verdeExito:AppColors.naranjaAlerta,size:18)))),
],
])),
);
@override void dispose(){ _desc.dispose(); super.dispose(); }
}
// ── Notif banner conductor ────────────────────────────────────────────────
class _NotifBanner extends StatelessWidget {
final AppNotification notif; final VoidCallback onDismiss;
const _NotifBanner({required this.notif, required this.onDismiss});
@override
Widget build(BuildContext context) {
final color = notif.event==NotifEvent.gpsLost?Colors.red.shade800
:notif.event==NotifEvent.truckStopped?AppColors.naranjaAlerta
:notif.event==NotifEvent.routeCancelled?AppColors.rojoError
:AppColors.moradoConductor;
return Material(color:Colors.transparent,
child:Container(margin:const EdgeInsets.all(10),
decoration:BoxDecoration(color:color,borderRadius:BorderRadius.circular(12),
boxShadow:const[BoxShadow(color:Colors.black26,blurRadius:6)]),
child:Padding(padding:const EdgeInsets.all(12),child:Row(children:[
const Icon(Icons.notification_important,color:Colors.white,size:22),
const SizedBox(width:8),
Expanded(child:Column(crossAxisAlignment:CrossAxisAlignment.start,
mainAxisSize:MainAxisSize.min,children:[
Text(notif.title,style:const TextStyle(color:Colors.white,fontWeight:FontWeight.bold,fontSize:13)),
Text(notif.body,style:const TextStyle(color:Colors.white70,fontSize:11),
maxLines:2,overflow:TextOverflow.ellipsis),
])),
IconButton(icon:const Icon(Icons.close,color:Colors.white,size:18),onPressed:onDismiss),
]))));
}
}

View File

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

View File

@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../core/app_colors.dart';
import '../data/colonies_data.dart';
import '../models/route_model.dart';
import '../services/auth_service.dart';
class RegisterScreen extends StatefulWidget {
const RegisterScreen({super.key});
@override State<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends State<RegisterScreen> {
final _nombre = TextEditingController();
final _email = TextEditingController();
final _pass = TextEditingController();
final _calle = TextEditingController();
ColonyModel? _colony;
bool _loading = false, _obscure = true;
Future<void> _register() async {
if ([_nombre,_email,_pass,_calle].any((c)=>c.text.trim().isEmpty) || _colony==null) {
_snack('Completa todos los campos', isError:true); return; }
if (_pass.text.length < 6) { _snack('Contraseña mínimo 6 caracteres', isError:true); return; }
setState(()=>_loading=true);
final err = await context.read<AuthService>().register(
nombre:_nombre.text, email:_email.text, password:_pass.text,
calle:_calle.text, colonia:_colony!.colonia,
routeId:_colony!.routeId, horarioEstimado:_colony!.horarioEstimado);
if (!mounted) return;
setState(()=>_loading=false);
if (err!=null) { _snack(err,isError:true); return; }
Navigator.pushReplacementNamed(context, '/home');
}
void _snack(String msg,{bool isError=false}) => ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content:Text(msg),
backgroundColor:isError?AppColors.rojoError:AppColors.verdeExito));
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(backgroundColor:AppColors.guindaPrimary,foregroundColor:Colors.white,
title:const Text('Registro Ciudadano'),
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
child:Container(height:4,color:AppColors.dorado))),
body: SingleChildScrollView(padding:const EdgeInsets.all(20), child:Column(children:[
_field(_nombre,'Nombre completo',Icons.badge_outlined),
const SizedBox(height:12),
_field(_email,'Correo electrónico',Icons.email_outlined,type:TextInputType.emailAddress),
const SizedBox(height:12),
TextField(controller:_pass,obscureText:_obscure,
decoration:InputDecoration(labelText:'Contraseña (mín. 6)',
prefixIcon:const Icon(Icons.lock_outline,color:AppColors.guindaPrimary),
border:const OutlineInputBorder(),filled:true,fillColor:Colors.white,
suffixIcon:IconButton(icon:Icon(_obscure?Icons.visibility_off:Icons.visibility),
onPressed:()=>setState(()=>_obscure=!_obscure)))),
const SizedBox(height:20),
const Align(alignment:Alignment.centerLeft,
child:Text('Domicilio',style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.guindaPrimary,fontSize:16))),
const SizedBox(height:10),
_field(_calle,'Calle y número',Icons.signpost_outlined),
const SizedBox(height:12),
DropdownButtonFormField<String>(
decoration:const InputDecoration(labelText:'Colonia',
prefixIcon:Icon(Icons.location_city,color:AppColors.guindaPrimary),
border:OutlineInputBorder(),filled:true,fillColor:Colors.white),
hint:const Text('Selecciona tu colonia'),
value:_colony?.colonia, isExpanded:true,
items:colonyNames.map((n)=>DropdownMenuItem(value:n,child:Text(n,style:const TextStyle(fontSize:13)))).toList(),
onChanged:(v){ if(v!=null) setState(()=>_colony=getColonyByName(v)); }),
if (_colony!=null) ...[
const SizedBox(height:10),
Container(padding:const EdgeInsets.all(12),
decoration:BoxDecoration(color:AppColors.guindaPrimary.withOpacity(0.08),
borderRadius:BorderRadius.circular(8),
border:Border.all(color:AppColors.guindaPrimary.withOpacity(0.3))),
child:Column(crossAxisAlignment:CrossAxisAlignment.start,children:[
Text('Ruta: ${_colony!.routeId}',style:const TextStyle(color:AppColors.guindaPrimary,fontWeight:FontWeight.bold)),
Text('Horario: ${_colony!.horarioEstimado}',style:const TextStyle(color:AppColors.grisTexto,fontSize:12)),
]))],
const SizedBox(height:24),
SizedBox(width:double.infinity,height:50,
child:ElevatedButton(onPressed:_loading?null:_register,
style:ElevatedButton.styleFrom(backgroundColor:AppColors.guindaPrimary,
foregroundColor:Colors.white,shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(8))),
child:_loading?const CircularProgressIndicator(color:Colors.white,strokeWidth:2)
:const Text('REGISTRARME',style:TextStyle(fontWeight:FontWeight.bold,letterSpacing:1)))),
const SizedBox(height:20),
])),
);
Widget _field(TextEditingController c, String label, IconData icon,
{TextInputType type=TextInputType.text}) =>
TextField(controller:c,keyboardType:type,
decoration:InputDecoration(labelText:label,
prefixIcon:Icon(icon,color:AppColors.guindaPrimary),
border:const OutlineInputBorder(),filled:true,fillColor:Colors.white));
@override void dispose(){ _nombre.dispose();_email.dispose();_pass.dispose();_calle.dispose();super.dispose(); }
}

View File

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

View File

@@ -0,0 +1,70 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/models.dart';
import '../database/db_helper.dart';
class AuthService extends ChangeNotifier {
UserModel? _user;
DomicilioModel? _domicilio;
bool _loading = true;
UserModel? get currentUser => _user;
DomicilioModel? get primaryDomicilio => _domicilio;
bool get isLoggedIn => _user != null;
bool get loading => _loading;
String get rol => _user?.rol ?? '';
AuthService() { _checkSession(); }
Future<void> _checkSession() async {
final p = await SharedPreferences.getInstance();
final id = p.getInt('user_id');
if (id != null) {
_user = await DbHelper.getUserById(id);
if (_user?.rol == 'CIUDADANO') {
_domicilio = await DbHelper.getPrimaryDomicilio(id);
}
}
_loading = false;
notifyListeners();
}
Future<String?> login(String email, String password) async {
final user = await DbHelper.getUserByEmail(email.trim().toLowerCase());
if (user == null) return 'Correo no registrado';
if (user.password != password) return 'Contraseña incorrecta';
_user = user;
if (user.rol == 'CIUDADANO') {
_domicilio = await DbHelper.getPrimaryDomicilio(user.id!);
}
final p = await SharedPreferences.getInstance();
await p.setInt('user_id', user.id!);
notifyListeners();
return null;
}
Future<String?> register({required String nombre, required String email,
required String password, required String calle, required String colonia,
required String routeId, required String horarioEstimado}) async {
final ex = await DbHelper.getUserByEmail(email.trim().toLowerCase());
if (ex != null) return 'Correo ya registrado';
final user = UserModel(nombre:nombre.trim(),
email:email.trim().toLowerCase(), password:password, rol:'CIUDADANO');
final uid = await DbHelper.insertUser(user);
await DbHelper.insertDomicilio(DomicilioModel(userId:uid, calle:calle.trim(),
colonia:colonia, routeId:routeId, horarioEstimado:horarioEstimado));
_user = await DbHelper.getUserById(uid);
_domicilio = await DbHelper.getPrimaryDomicilio(uid);
final p = await SharedPreferences.getInstance();
await p.setInt('user_id', uid);
notifyListeners();
return null;
}
Future<void> logout() async {
_user = null; _domicilio = null;
final p = await SharedPreferences.getInstance();
await p.remove('user_id');
notifyListeners();
}
}

View File

@@ -0,0 +1,204 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/route_model.dart';
import '../models/models.dart';
import '../data/routes_data.dart';
import '../database/db_helper.dart';
enum NotifEvent { routeStart, truckProximity, routeCompleted, gpsLost, truckStopped, routeCancelled, none }
class AppNotification {
final NotifEvent event;
final String title;
final String body;
final String routeId; // Para filtrar por usuario
final DateTime timestamp;
AppNotification({required this.event, required this.title,
required this.body, required this.routeId})
: timestamp = DateTime.now();
}
class SimulatorState {
final String routeId;
int positionIndex;
bool gpsActive;
DateTime lastMoved;
bool stoppedAlertSent;
SimulatorState({required this.routeId, this.positionIndex = 0,
this.gpsActive = true, required this.lastMoved, this.stoppedAlertSent = false});
}
class RouteSimulatorService extends ChangeNotifier {
final Map<String, SimulatorState> _states = {};
Timer? _globalTimer;
Timer? _gpsMonitorTimer;
AppNotification? _lastNotification; // Admin ve todas
final List<AppNotification> _history = [];
// ── Getters ─────────────────────────────────────────────────────────────
// Admin: ve la última notificación global
AppNotification? get lastNotification => _lastNotification;
// Ciudadano/Conductor: solo ve notificaciones de SU ruta
AppNotification? getNotificationForRoute(String routeId) {
if (_lastNotification?.routeId == routeId) return _lastNotification;
return null;
}
List<AppNotification> get history => List.unmodifiable(_history);
// Historial filtrado por ruta
List<AppNotification> historyForRoute(String routeId) =>
_history.where((n) => n.routeId == routeId).toList();
// ── Inicio ───────────────────────────────────────────────────────────────
void startAllRoutes() {
for (final r in routesData) {
_states[r.routeId] = SimulatorState(routeId: r.routeId, lastMoved: DateTime.now());
}
_globalTimer?.cancel();
_globalTimer = Timer.periodic(const Duration(seconds: 30), (_) => _tick());
_gpsMonitorTimer?.cancel();
_gpsMonitorTimer = Timer.periodic(const Duration(minutes: 5), (_) => _monitorGps());
notifyListeners();
}
void startRoute(String routeId) {
_states[routeId] = SimulatorState(routeId: routeId, lastMoved: DateTime.now());
_globalTimer ??= Timer.periodic(const Duration(seconds: 30), (_) => _tick());
notifyListeners();
}
void _tick() {
bool changed = false;
for (final entry in _states.entries) {
final state = entry.value;
final route = getRouteById(state.routeId);
if (route == null || !state.gpsActive) continue;
if (state.positionIndex < route.positions.length - 1) {
state.positionIndex++;
state.lastMoved = DateTime.now();
_checkNotification(state, route);
changed = true;
}
}
if (changed) notifyListeners();
}
void _monitorGps() {
for (final state in _states.values) {
if (!state.gpsActive) continue;
final diff = DateTime.now().difference(state.lastMoved);
if (diff.inMinutes >= 30 && !state.stoppedAlertSent) {
state.stoppedAlertSent = true;
_fireAndSaveAlert(
event: NotifEvent.truckStopped, routeId: state.routeId,
title: '⚠️ Camión detenido',
body: 'El camión ${state.routeId} lleva +30 min sin moverse.',
tipo: 'CAMION_DETENIDO',
);
}
}
}
void _checkNotification(SimulatorState state, RouteModel route) {
if (state.positionIndex == 1) {
_fireNotif(NotifEvent.routeStart, '¡Ruta Iniciada! 🚛',
'El camión ha salido del Relleno Sanitario rumbo a tu sector.', state.routeId);
} else if (state.positionIndex == 3) {
_fireNotif(NotifEvent.truckProximity, 'Camión Cercano ⚠️',
'El camión está a menos de 15 minutos. ¡Saca tus bolsas!', state.routeId);
} else if (state.positionIndex == route.positions.length - 1) {
_fireNotif(NotifEvent.routeCompleted, 'Servicio Finalizado 🏁',
'El camión de ${state.routeId} concluyó su jornada.', state.routeId);
}
}
void _fireNotif(NotifEvent event, String title, String body, String routeId) {
final n = AppNotification(event: event, title: title, body: body, routeId: routeId);
_lastNotification = n;
_history.insert(0, n);
notifyListeners();
}
Future<void> _fireAndSaveAlert({required NotifEvent event, required String routeId,
required String title, required String body, required String tipo}) async {
_fireNotif(event, title, body, routeId);
await DbHelper.insertAlerta(AlertaModel(
tipo: tipo, routeId: routeId, mensaje: body,
fecha: DateTime.now().toIso8601String()));
}
// Notificación manual (admin cancela, retrasa ruta, etc.)
void fireCustomNotification(String title, String body, String routeId, NotifEvent event) {
_fireNotif(event, title, body, routeId);
}
// ── GPS ──────────────────────────────────────────────────────────────────
Future<void> simulateGpsLost(String routeId) async {
final state = _states[routeId];
if (state == null) return;
state.gpsActive = false;
await _fireAndSaveAlert(
event: NotifEvent.gpsLost, routeId: routeId,
title: '📡 GPS Desactivado',
body: 'Se perdió la señal GPS del camión $routeId.',
tipo: 'GPS_PERDIDO',
);
notifyListeners();
}
void restoreGps(String routeId) {
final state = _states[routeId];
if (state == null) return;
state.gpsActive = true;
state.stoppedAlertSent = false;
notifyListeners();
}
// ── Getters de estado ────────────────────────────────────────────────────
SimulatorState? getState(String routeId) => _states[routeId];
int getPositionIndex(String routeId) => _states[routeId]?.positionIndex ?? 0;
bool isTruckClose(String routeId) => getPositionIndex(routeId) >= 3;
bool isGpsActive(String routeId) => _states[routeId]?.gpsActive ?? true;
String getEtaText(String routeId) {
final state = _states[routeId];
if (state == null) return 'Sin información';
if (!state.gpsActive) return '📡 Señal GPS perdida';
final route = getRouteById(routeId);
if (route == null) return 'Ruta no encontrada';
final idx = state.positionIndex;
if (idx >= route.positions.length) return '✅ Servicio finalizado';
switch (idx) {
case 0: return '🕐 Ruta por iniciar';
case 1: return '🚛 Camión en camino';
case 2: return '🚛 Aprox. 30 min para llegar';
case 3: return '⚠️ Menos de 15 min — ¡Saca tus bolsas!';
case 4: return '🔔 El camión está en tu zona';
case 5: return '✅ Pasando por tu colonia';
case 6: return '↩️ Regresando al relleno';
default: return '🏁 Servicio del día finalizado';
}
}
void dismissNotification() {
_lastNotification = null;
notifyListeners();
}
void dismissRouteNotification(String routeId) {
if (_lastNotification?.routeId == routeId) {
_lastNotification = null;
notifyListeners();
}
}
@override
void dispose() {
_globalTimer?.cancel();
_gpsMonitorTimer?.cancel();
super.dispose();
}
}

View File

@@ -0,0 +1,146 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../core/app_colors.dart';
import '../models/route_model.dart';
import '../services/route_simulator_service.dart';
List<LatLng> _smooth(List<LatLng> pts) {
if (pts.length < 2) return pts;
final res = <LatLng>[];
for (int i = 0; i < pts.length - 1; i++) {
final p0 = pts[i > 0 ? i - 1 : i];
final p1 = pts[i];
final p2 = pts[i + 1];
final p3 = pts[i + 2 < pts.length ? i + 2 : i + 1];
res.add(p1);
for (int j = 1; j <= 8; j++) {
final t = j / 9.0;
res.add(LatLng(_cr(p0.latitude, p1.latitude, p2.latitude, p3.latitude, t),
_cr(p0.longitude, p1.longitude, p2.longitude, p3.longitude, t)));
}
}
res.add(pts.last);
return res;
}
double _cr(double a, double b, double c, double d, double t) => 0.5 * (
(2*b) + (-a+c)*t + (2*a-5*b+4*c-d)*t*t + (-a+3*b-3*c+d)*t*t*t);
class RouteMapWidget extends StatelessWidget {
final RouteModel route;
final RouteSimulatorService simulator;
final bool showFullRoute;
final double height;
const RouteMapWidget({super.key, required this.route, required this.simulator,
this.showFullRoute = false, this.height = 300});
@override
Widget build(BuildContext context) {
final posIdx = simulator.getPositionIndex(route.routeId);
final cur = posIdx < route.positions.length
? route.positions[posIdx].latLng : route.positions.last.latLng;
final gps = simulator.isGpsActive(route.routeId);
final all = _smooth(route.polylinePoints);
final done = posIdx > 0
? _smooth(route.positions.take(posIdx+1).map((p) => p.latLng).toList())
: <LatLng>[];
return ClipRRect(borderRadius: BorderRadius.circular(12),
child: SizedBox(height: height,
child: FlutterMap(
options: MapOptions(initialCenter: cur, initialZoom: 14),
children: [
TileLayer(urlTemplate:'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName:'com.example.celaya_limpia'),
PolylineLayer(polylines:[
Polyline(points:all, color:Colors.grey.withOpacity(0.4), strokeWidth:4,
borderColor:Colors.white54, borderStrokeWidth:1),
]),
if (done.isNotEmpty) PolylineLayer(polylines:[
Polyline(points:done, color:AppColors.guindaPrimary, strokeWidth:5,
borderColor:AppColors.guindaDark, borderStrokeWidth:1),
]),
MarkerLayer(markers:[
Marker(point:cur, width:52, height:52,
child:Container(decoration:BoxDecoration(
color:gps?AppColors.guindaPrimary:Colors.grey, shape:BoxShape.circle,
border:Border.all(color:Colors.white,width:2.5),
boxShadow:[BoxShadow(color:Colors.black38,blurRadius:6)]),
child:Icon(gps?Icons.local_shipping:Icons.gps_off,color:Colors.white,size:24))),
Marker(point:route.positions.first.latLng, width:28, height:28,
child:Container(decoration:BoxDecoration(color:AppColors.verdeExito,shape:BoxShape.circle,
border:Border.all(color:Colors.white,width:2)),
child:const Icon(Icons.circle,color:Colors.white,size:10))),
Marker(point:route.positions.last.latLng, width:32, height:32,
child:Container(decoration:BoxDecoration(color:AppColors.rojoError,shape:BoxShape.circle,
border:Border.all(color:Colors.white,width:2)),
child:const Icon(Icons.flag,color:Colors.white,size:16))),
]),
])));
}
}
class AdminMapWidget extends StatefulWidget {
final List<RouteModel> routes;
final RouteSimulatorService simulator;
const AdminMapWidget({super.key, required this.routes, required this.simulator});
@override State<AdminMapWidget> createState() => _AdminMapWidgetState();
}
class _AdminMapWidgetState extends State<AdminMapWidget> {
String? _sel;
static const _colors = [
Colors.blue, Colors.green, Colors.orange, Colors.purple, Colors.teal,
Colors.red, Colors.indigo, Colors.brown, Colors.cyan, Colors.pink,
Colors.amber, Colors.lime, Colors.deepOrange, Colors.lightBlue, Colors.deepPurple,
];
@override
Widget build(BuildContext context) {
return Column(children:[
Expanded(child: FlutterMap(
options: const MapOptions(initialCenter:LatLng(20.5211,-100.8196), initialZoom:12),
children:[
TileLayer(urlTemplate:'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName:'com.example.celaya_limpia'),
PolylineLayer(polylines: widget.routes.asMap().entries.map((e){
final c = _colors[e.key % _colors.length];
final isS = _sel==null||_sel==e.value.routeId;
return Polyline(points:_smooth(e.value.polylinePoints),
color:c.withOpacity(isS?0.85:0.2), strokeWidth:isS?4:1.5,
borderColor:Colors.white.withOpacity(isS?0.4:0), borderStrokeWidth:1);
}).toList()),
MarkerLayer(markers: widget.routes.asMap().entries.map((e){
final r = e.value;
final idx = widget.simulator.getPositionIndex(r.routeId);
final pos = idx < r.positions.length ? r.positions[idx].latLng : r.positions.last.latLng;
final c = _colors[e.key % _colors.length];
final gps = widget.simulator.isGpsActive(r.routeId);
return Marker(point:pos, width:46, height:46,
child:GestureDetector(
onTap:()=>setState(()=>_sel=_sel==r.routeId?null:r.routeId),
child:Container(decoration:BoxDecoration(color:gps?c:Colors.grey,
shape:BoxShape.circle, border:Border.all(color:Colors.white,width:2),
boxShadow:[BoxShadow(color:Colors.black26,blurRadius:4)]),
child:Center(child:Text(r.truckId.toString().substring(1),
style:const TextStyle(color:Colors.white,fontSize:11,fontWeight:FontWeight.bold))))));
}).toList()),
],
)),
if (_sel!=null) ...[
const Divider(height:1),
Container(padding:const EdgeInsets.symmetric(horizontal:16,vertical:10),
color:AppColors.guindaPrimary,
child:Row(children:[
const Icon(Icons.local_shipping,color:Colors.white,size:16),
const SizedBox(width:8),
Expanded(child:Text(widget.routes.firstWhere((r)=>r.routeId==_sel).name,
style:const TextStyle(color:Colors.white,fontWeight:FontWeight.bold,fontSize:13))),
Text('Pos ${widget.simulator.getPositionIndex(_sel!)}/7',
style:const TextStyle(color:AppColors.dorado,fontSize:12)),
])),
],
]);
}
}