From 45355f2c9273c020a453867df80f1e59638e5062 Mon Sep 17 00:00:00 2001 From: Erick Cesar Mondragon Palacios Date: Fri, 22 May 2026 21:49:21 -0600 Subject: [PATCH] Finaliza interfaces de operador y administrador --- lib/main.dart | 492 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 450 insertions(+), 42 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index d02fe7e..c955401 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -557,6 +557,32 @@ class Repo { await prefs.remove('alertas_operativas'); } + static Future>> cargarSugerencias() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString('sugerencias') ?? '[]'; + final list = jsonDecode(raw) as List; + + return list.map((e) => Map.from(e)).toList(); + } + + static bool emailValido(String value) { + final text = value.trim(); + final regex = RegExp(r'^[^\s@]+@[^\s@]+\.[^\s@]+$'); + return regex.hasMatch(text); + } + + static bool direccionValida(String value) { + final text = value.trim(); + final tieneLetras = RegExp(r'[A-Za-zÁÉÍÓÚáéíóúÑñ]').hasMatch(text); + final tieneNumero = RegExp(r'\d').hasMatch(text); + return text.length >= 8 && tieneLetras && tieneNumero; + } + + static bool coloniaPermitida(String value) { + final text = normalizar(value.trim()); + return colonias().any((c) => normalizar(c.colonia) == text); + } + static String fechaCorta(DateTime now) { final d = now.day.toString().padLeft(2, '0'); final m = now.month.toString().padLeft(2, '0'); @@ -692,7 +718,7 @@ class _LoginPageState extends State { if (correo == 'admin@demo.com' && password == '123456') { Navigator.pushReplacement( context, - MaterialPageRoute(builder: (_) => const AdminConceptPage()), + MaterialPageRoute(builder: (_) => const AdminPage()), ); return; } @@ -873,6 +899,16 @@ class _HomePageState extends State { title: const Text('Recolector Inteligente'), actions: [ IconButton(onPressed: cargar, icon: const Icon(Icons.refresh)), + IconButton( + tooltip: 'Cerrar sesión', + onPressed: () { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const LoginPage()), + ); + }, + icon: const Icon(Icons.logout), + ), ], ), body: RefreshIndicator( @@ -1224,17 +1260,51 @@ class _DatosPageState extends State with SingleTickerProviderStateMix } Future guardarRegistro() async { - if (nombre.text.trim().isEmpty || telefono.text.trim().isEmpty) { + final nombreTxt = nombre.text.trim(); + final telefonoTxt = telefono.text.trim(); + final correoTxt = correo.text.trim(); + final direccionTxt = direccionPrincipal.text.trim(); + final coloniaTxt = coloniaPrincipal.text.trim(); + + if (nombreTxt.isEmpty || telefonoTxt.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Nombre y teléfono son obligatorios')), ); return; } + if (!Repo.emailValido(correoTxt)) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Ingresa un correo válido. Ejemplo: usuario@correo.com')), + ); + return; + } + + if (!Repo.direccionValida(direccionTxt)) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Ingresa una dirección válida con calle y número.')), + ); + return; + } + + if (!Repo.coloniaPermitida(coloniaTxt)) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Selecciona una colonia válida de la lista oficial.')), + ); + return; + } + + if (latPrincipal == null || lngPrincipal == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Selecciona tu domicilio en el mapa para validar la ubicación.')), + ); + return; + } + await Repo.guardarUsuario( - nombre: nombre.text.trim(), - telefono: telefono.text.trim(), - correo: correo.text.trim(), + nombre: nombreTxt, + telefono: telefonoTxt, + correo: correoTxt, rfc: rfc.text.trim(), ); @@ -1271,9 +1341,26 @@ class _DatosPageState extends State with SingleTickerProviderStateMix } Future agregarDomicilio() async { - if (direccionExtra.text.trim().isEmpty) { + final direccionTxt = direccionExtra.text.trim(); + final coloniaTxt = coloniaExtra.text.trim(); + + if (!Repo.direccionValida(direccionTxt)) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Escribe el domicilio a agregar')), + const SnackBar(content: Text('Escribe una dirección válida con calle y número.')), + ); + return; + } + + if (!Repo.coloniaPermitida(coloniaTxt)) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Selecciona una colonia válida de la lista oficial.')), + ); + return; + } + + if (latExtra == null || lngExtra == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Selecciona este domicilio en el mapa.')), ); return; } @@ -1437,6 +1524,18 @@ class _DatosPageState extends State with SingleTickerProviderStateMix return Scaffold( appBar: AppBar( title: const Text('Datos personales'), + actions: [ + IconButton( + tooltip: 'Cerrar sesión', + onPressed: () { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const LoginPage()), + ); + }, + icon: const Icon(Icons.logout), + ), + ], bottom: TabBar( controller: tab, tabs: const [ @@ -2681,66 +2780,373 @@ class _OperadorPageState extends State { // ADMINISTRADOR CONCEPTUAL / FUTURO // ======================================================= -class AdminConceptPage extends StatelessWidget { - const AdminConceptPage({super.key}); +class AdminPage extends StatefulWidget { + const AdminPage({super.key}); + + @override + State createState() => _AdminPageState(); +} + +class _AdminPageState extends State with SingleTickerProviderStateMixin { + late TabController tab; + List alertas = []; + List> reportes = []; + List servicios = []; + List rutas = []; + + @override + void initState() { + super.initState(); + tab = TabController(length: 4, vsync: this); + cargar(); + } + + Future cargar() async { + final a = await Repo.cargarAlertasOperativas(); + final r = await Repo.cargarSugerencias(); + final s = await Repo.cargarServicios(); + + if (!mounted) return; + + setState(() { + alertas = a; + reportes = r; + servicios = s; + rutas = Repo.rutas(); + }); + } + + int get alertasCriticas => alertas.where((a) => a.prioridad >= 3).length; + int get retrasos => alertas.where((a) => a.tipo == 'DELAY').length; + int get averias => alertas.where((a) => a.tipo == 'MECHANICAL_FAILURE').length; + + double get promedioServicio { + if (servicios.isEmpty) return 0; + final total = servicios.fold(0, (sum, s) => sum + s.estrellas); + return total / servicios.length; + } + + Future limpiarDemo() async { + await Repo.limpiarAlertasOperativas(); + await cargar(); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Alertas operativas limpiadas para la demo')), + ); + } + + Widget kpiCard({ + required IconData icon, + required String title, + required String value, + required Color color, + }) { + return Expanded( + child: AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: color, size: 30), + const SizedBox(height: 8), + Text(title, style: const TextStyle(fontWeight: FontWeight.w800)), + const SizedBox(height: 4), + Text(value, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w900)), + ], + ), + ), + ); + } + + Widget alertaTile(AlertaOperativa alerta) { + Color color = AppColors.green; + IconData icon = Icons.check_circle; + + if (alerta.prioridad == 2) { + color = AppColors.orange; + icon = Icons.timer; + } + + if (alerta.prioridad >= 3) { + color = AppColors.red; + icon = Icons.warning_amber; + } - Widget item(IconData icon, String title, String subtitle, Color color) { return Card( - child: ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: ExpansionTile( leading: CircleAvatar( backgroundColor: color.withOpacity(0.12), child: Icon(icon, color: color), ), - title: Text(title, style: const TextStyle(fontWeight: FontWeight.w900)), - subtitle: Text(subtitle), + title: Text(alerta.titulo, style: const TextStyle(fontWeight: FontWeight.w900)), + subtitle: Text('${alerta.routeId} · Camión ${alerta.truckId} · ${alerta.fecha}'), + childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + children: [ + Align( + alignment: Alignment.centerLeft, + child: Text(alerta.mensaje, style: const TextStyle(fontSize: 16)), + ), + const SizedBox(height: 10), + Row( + children: [ + Chip(label: Text('Tipo: ${alerta.tipo}')), + const SizedBox(width: 8), + Chip(label: Text('Prioridad ${alerta.prioridad}')), + ], + ), + Align( + alignment: Alignment.centerLeft, + child: Text( + 'Operador: ${alerta.operador}', + style: TextStyle(color: Colors.grey.shade700, fontWeight: FontWeight.w700), + ), + ), + ], ), ); } + Widget reporteTile(Map reporte) { + final texto = (reporte['texto'] ?? '').toString(); + final fechaRaw = (reporte['fecha'] ?? '').toString(); + final fecha = fechaRaw.length >= 16 ? fechaRaw.substring(0, 16).replaceAll('T', ' ') : fechaRaw; + + IconData icon = Icons.feedback; + Color color = AppColors.green; + + if (texto.toLowerCase().contains('camión no pasó') || texto.toLowerCase().contains('no pasó')) { + icon = Icons.cancel; + color = AppColors.red; + } else if (texto.toLowerCase().contains('retraso')) { + icon = Icons.timer; + color = AppColors.orange; + } + + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: color.withOpacity(0.12), + child: Icon(icon, color: color), + ), + title: Text(texto, style: const TextStyle(fontWeight: FontWeight.w800)), + subtitle: Text(fecha.isEmpty ? 'Reporte ciudadano' : fecha), + trailing: const Chip(label: Text('Nuevo')), + ), + ); + } + + Widget rutaTile(RutaOficial ruta) { + final inicio = ruta.positions.first.timestamp.substring(11, 16); + final fin = ruta.positions.last.timestamp.substring(11, 16); + final estadoColor = ruta.status == 'EN_RUTA' ? AppColors.green : Colors.grey; + + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: estadoColor.withOpacity(0.12), + child: Icon(Icons.local_shipping, color: estadoColor), + ), + title: Text('${ruta.routeId} · ${ruta.name}', style: const TextStyle(fontWeight: FontWeight.w900)), + subtitle: Text('Camión ${ruta.truckId} · $inicio - $fin · ${ruta.positions.length} puntos'), + trailing: Chip( + label: Text(ruta.status), + backgroundColor: estadoColor.withOpacity(0.12), + ), + ), + ); + } + + Widget operatorTile(String name, String ruta, String camion, String status, Color color) { + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: color.withOpacity(0.12), + child: Icon(Icons.engineering, color: color), + ), + title: Text(name, style: const TextStyle(fontWeight: FontWeight.w900)), + subtitle: Text('$ruta · $camion'), + trailing: Chip(label: Text(status)), + ), + ); + } + + Widget resumenTab() { + return RefreshIndicator( + onRefresh: cargar, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + AppCard( + color: AppColors.softGreen, + child: Row( + children: [ + const CircleAvatar( + radius: 34, + backgroundColor: AppColors.green, + child: Icon(Icons.admin_panel_settings, color: Colors.white, size: 40), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Text('Centro de control logístico', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w900)), + SizedBox(height: 4), + Text('Panel administrador para supervisar rutas, operadores, camiones y reportes.'), + ], + ), + ), + ], + ), + ), + Row( + children: [ + kpiCard(icon: Icons.alt_route, title: 'Rutas', value: '${rutas.length}', color: AppColors.green), + kpiCard(icon: Icons.warning_amber, title: 'Críticas', value: '$alertasCriticas', color: AppColors.red), + ], + ), + Row( + children: [ + kpiCard(icon: Icons.feedback, title: 'Reportes', value: '${reportes.length}', color: AppColors.orange), + kpiCard(icon: Icons.star, title: 'Servicio', value: promedioServicio == 0 ? '—' : promedioServicio.toStringAsFixed(1), color: Colors.amber), + ], + ), + const SectionTitle('Operadores en turno'), + operatorTile('José Martínez', 'RUTA-01', 'Camión 101', 'En ruta', AppColors.green), + operatorTile('María López', 'RUTA-03', 'Camión 103', averias > 0 ? 'Requiere apoyo' : 'Disponible', averias > 0 ? AppColors.red : AppColors.orange), + operatorTile('Carlos Ramírez', 'RUTA-05', 'Camión 105', retrasos > 0 ? 'Retraso' : 'Programado', retrasos > 0 ? AppColors.orange : Colors.blueGrey), + const SectionTitle('Acciones administrativas'), + Row( + children: [ + Expanded( + child: SizedBox( + height: 56, + child: FilledButton.icon( + onPressed: cargar, + icon: const Icon(Icons.sync), + label: const Text('Actualizar'), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: SizedBox( + height: 56, + child: OutlinedButton.icon( + onPressed: limpiarDemo, + icon: const Icon(Icons.cleaning_services), + label: const Text('Limpiar demo'), + ), + ), + ), + ], + ), + ], + ), + ); + } + + Widget alertasTab() { + return RefreshIndicator( + onRefresh: cargar, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + const SectionTitle('Alertas operativas', subtitle: 'Eventos enviados desde la interfaz del operador.'), + if (alertas.isEmpty) + const AppCard(child: Text('Todavía no hay alertas. Entra como operador y reporta una avería o retraso.')) + else + ...alertas.map(alertaTile), + ], + ), + ); + } + + Widget reportesTab() { + return RefreshIndicator( + onRefresh: cargar, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + const SectionTitle('Reportes ciudadanos', subtitle: 'Quejas y sugerencias enviadas desde el buzón.'), + if (reportes.isEmpty) + const AppCard(child: Text('Todavía no hay reportes ciudadanos.')) + else + ...reportes.map(reporteTile), + const SectionTitle('Calificaciones recientes'), + if (servicios.isEmpty) + const AppCard(child: Text('Todavía no hay calificaciones registradas.')) + else + ...servicios.map((s) => Card( + child: ListTile( + leading: const Icon(Icons.star, color: Colors.amber), + title: Text(s.domicilio, style: const TextStyle(fontWeight: FontWeight.w800)), + subtitle: Text('${s.estrellas}/5 · ${s.fecha}'), + ), + )), + ], + ), + ); + } + + Widget rutasTab() { + return RefreshIndicator( + onRefresh: cargar, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + const SectionTitle('Rutas y flotilla', subtitle: 'Vista logística general. En producción aquí se reasignan camiones.'), + ...rutas.map(rutaTile), + const SizedBox(height: 16), + const Text( + 'En el backend real este módulo tendría permisos RBAC para crear rutas, asignar operadores y despachar unidades de reemplazo.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.black54), + ), + ], + ), + ); + } + + @override + void dispose() { + tab.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Administrador'), actions: [ + IconButton(onPressed: cargar, icon: const Icon(Icons.refresh)), IconButton( + tooltip: 'Cerrar sesión', onPressed: () { Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const LoginPage())); }, icon: const Icon(Icons.logout), ), ], + bottom: TabBar( + controller: tab, + isScrollable: true, + tabs: const [ + Tab(icon: Icon(Icons.dashboard), text: 'Resumen'), + Tab(icon: Icon(Icons.warning_amber), text: 'Alertas'), + Tab(icon: Icon(Icons.feedback), text: 'Reportes'), + Tab(icon: Icon(Icons.alt_route), text: 'Rutas'), + ], + ), ), - body: ListView( - padding: const EdgeInsets.all(16), + body: TabBarView( + controller: tab, children: [ - AppCard( - color: AppColors.softGreen, - child: Column( - children: const [ - Icon(Icons.admin_panel_settings, size: 76, color: AppColors.green), - SizedBox(height: 12), - Text( - 'Módulo logístico propuesto', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 28, fontWeight: FontWeight.w900), - ), - SizedBox(height: 8), - Text( - 'No se desarrolla en este MVP por alcance del hackathon, pero queda contemplado dentro de la arquitectura por roles.', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 17, height: 1.35), - ), - ], - ), - ), - const SectionTitle('Funciones futuras'), - item(Icons.alt_route, 'Asignación de rutas', 'Crear, editar y asignar rutas por zona de cobertura.', AppColors.green), - item(Icons.local_shipping, 'Gestión de camiones', 'Asignar unidades, revisar disponibilidad y controlar mantenimientos.', Colors.indigo), - item(Icons.engineering, 'Asignación de operadores', 'Vincular choferes con camiones, turnos y rutas.', AppColors.orange), - item(Icons.analytics, 'Indicadores logísticos', 'Medir retrasos, cumplimiento, incidencias y calificaciones.', Colors.blueGrey), - item(Icons.security, 'Control RBAC', 'Separar permisos entre ciudadano, operador y administrador.', AppColors.red), + resumenTab(), + alertasTab(), + reportesTab(), + rutasTab(), ], ), ); @@ -2748,6 +3154,8 @@ class AdminConceptPage extends StatelessWidget { } + + // ======================================================= // BUZÓN // =======================================================