Files
AppRecoleccion/lib/screens/admin/admin_dashboard_screen.dart
2026-05-22 20:43:49 -06:00

1030 lines
50 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 '../../models/route_model.dart' show ColonyModel;
import 'create_route_screen.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),
_AdminRoutesTab(),
_AdminReviewsTab(),
];
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'),
NavigationDestination(icon:Icon(Icons.route_outlined),
selectedIcon:Icon(Icons.route,color:AppColors.verdeAdmin),label:'Rutas'),
NavigationDestination(icon:Icon(Icons.star_outline),
selectedIcon:Icon(Icons.star,color:AppColors.verdeAdmin),label:'Reseñas'),
],
),
);
}
}
// ── 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),
]))));
}
// ── TAB 6: Gestión de Rutas ───────────────────────────────────────────────
class _AdminRoutesTab extends StatefulWidget {
@override State<_AdminRoutesTab> createState() => _AdminRoutesTabState();
}
class _AdminRoutesTabState extends State<_AdminRoutesTab> {
List<RouteDefinitionModel> _routes = [];
bool _loading = true;
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final r = await DbHelper.getAllRouteDefinitions();
if (mounted) setState(() { _routes = r; _loading = false; });
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(automaticallyImplyLeading: false,
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
title: Text('Rutas del Sistema (${_routes.length})'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
actions: [
IconButton(icon: const Icon(Icons.refresh), onPressed: _load),
IconButton(
icon: const Icon(Icons.add_circle_outline),
tooltip: 'Nueva ruta',
onPressed: () async {
final ok = await Navigator.push(context, MaterialPageRoute(
builder: (_) => const CreateRouteScreen()));
if (ok == true) await _load();
}),
]),
body: _loading
? const Center(child: CircularProgressIndicator())
: _routes.isEmpty
? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
const Icon(Icons.route, color: AppColors.grisTexto, size: 48),
const SizedBox(height: 12),
const Text('No hay rutas creadas', style: TextStyle(color: AppColors.grisTexto)),
const SizedBox(height: 16),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(backgroundColor: AppColors.verdeAdmin,
foregroundColor: Colors.white),
onPressed: () async {
final ok = await Navigator.push(context, MaterialPageRoute(
builder: (_) => const CreateRouteScreen()));
if (ok == true) await _load();
},
icon: const Icon(Icons.add), label: const Text('Crear primera ruta')),
]))
: ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: _routes.length,
itemBuilder: (_, i) {
final r = _routes[i];
final turnoEmoji = r.turno == 'MATUTINO' ? '🌄'
: r.turno == 'VESPERTINO' ? '🌅' : '🌙';
return Card(margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10),
side: BorderSide(color: AppColors.verdeAdmin.withOpacity(0.3))),
child: Padding(padding: const EdgeInsets.all(14), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Expanded(child: Text('${r.routeId}${r.nombre}',
style: const TextStyle(fontWeight: FontWeight.bold,
fontSize: 14, color: AppColors.verdeAdmin))),
IconButton(icon: const Icon(Icons.edit_outlined, size: 18),
onPressed: () async {
final ok = await Navigator.push(context, MaterialPageRoute(
builder: (_) => CreateRouteScreen(editing: r)));
if (ok == true) await _load();
}),
]),
const SizedBox(height: 4),
Row(children: [
Text('$turnoEmoji ${r.turno}${r.horaInicio}${r.horaFin}',
style: const TextStyle(color: AppColors.grisTexto, fontSize: 12)),
]),
const SizedBox(height: 4),
Text(r.dias.map(AppDias.label).join(', '),
style: const TextStyle(fontSize: 11, color: AppColors.grisTexto)),
const SizedBox(height: 6),
// Colonias
Text('📍 ${r.colonias.length} colonias:',
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12)),
const SizedBox(height: 4),
Wrap(spacing: 4, runSpacing: 4, children: r.colonias.take(8).map((c) =>
Container(padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(color: AppColors.verdeAdmin.withOpacity(0.1),
borderRadius: BorderRadius.circular(8)),
child: Text(c, style: const TextStyle(fontSize: 10,
color: AppColors.verdeAdmin)))).toList()),
if (r.colonias.length > 8)
Text(' ...y ${r.colonias.length - 8} más',
style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
])));
}),
);
}
// ── TAB 7: Reseñas y calificaciones ──────────────────────────────────────
class _AdminReviewsTab extends StatefulWidget {
@override State<_AdminReviewsTab> createState() => _AdminReviewsTabState();
}
class _AdminReviewsTabState extends State<_AdminReviewsTab> {
List<ReviewModel> _reviews = [];
List<Map<String, dynamic>> _summary = [];
bool _showSummary = false;
bool _loading = true;
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final r = await DbHelper.getAllReviews();
final s = await DbHelper.getReviewSummaryByColonia();
if (mounted) setState(() { _reviews = r; _summary = s; _loading = false; });
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(automaticallyImplyLeading: false,
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
title: Text(_showSummary ? 'Calificaciones por Colonia' : 'Reseñas Ciudadanas'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
actions: [
IconButton(
icon: Icon(_showSummary ? Icons.list : Icons.bar_chart),
tooltip: _showSummary ? 'Ver reseñas' : 'Ver por colonia',
onPressed: () => setState(() => _showSummary = !_showSummary)),
IconButton(icon: const Icon(Icons.refresh), onPressed: _load),
]),
body: _loading
? const Center(child: CircularProgressIndicator())
: _showSummary ? _buildSummary() : _buildReviews(),
);
Widget _buildSummary() {
if (_summary.isEmpty) return const Center(
child: Text('Sin calificaciones aún', style: TextStyle(color: AppColors.grisTexto)));
return Column(children: [
// Header explicativo
Container(margin: const EdgeInsets.all(12), padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: Colors.blue.shade50, borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.shade200)),
child: const Row(children: [
Icon(Icons.info_outline, color: AppColors.azulInfo, size: 16),
SizedBox(width: 6),
Expanded(child: Text(
'Colonias ordenadas de menor a mayor calificación. '
'Las primeras requieren atención prioritaria.',
style: TextStyle(fontSize: 11, color: AppColors.azulInfo))),
])),
Expanded(child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: _summary.length,
itemBuilder: (_, i) {
final s = _summary[i];
final prom = (s['promedio'] as num).toDouble();
final total = s['total'] as int;
final colonia = s['colonia'] as String;
final routeId = s['route_id'] as String;
final color = prom >= 4.5 ? AppColors.verdeExito
: prom >= 3.5 ? Colors.amber.shade700
: prom >= 2.5 ? AppColors.naranjaAlerta
: AppColors.rojoError;
final emoji = prom >= 4.5 ? '🟢' : prom >= 3.5 ? '🟡' : prom >= 2.5 ? '🟠' : '🔴';
return Card(margin: const EdgeInsets.only(bottom: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10),
side: BorderSide(color: color.withOpacity(0.3))),
child: Padding(padding: const EdgeInsets.all(12), child: Row(children: [
Container(width: 6, height: 50,
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(3))),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(colonia, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
Text('$emoji $routeId$total reseña${total != 1 ? "s" : ""}',
style: const TextStyle(fontSize: 11, color: AppColors.grisTexto)),
])),
Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
Text(prom.toStringAsFixed(1),
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: color)),
Row(children: List.generate(5, (j) =>
Icon(j < prom.round() ? Icons.star : Icons.star_border,
color: Colors.amber, size: 12))),
]),
])));
})),
]);
}
Widget _buildReviews() {
if (_reviews.isEmpty) return const Center(
child: Text('Sin reseñas aún', style: TextStyle(color: AppColors.grisTexto)));
return ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: _reviews.length,
itemBuilder: (_, i) {
final r = _reviews[i];
final fecha = DateTime.tryParse(r.fecha);
final fechaStr = fecha != null
? '${fecha.day}/${fecha.month}/${fecha.year} ${fecha.hour.toString().padLeft(2,'0')}:${fecha.minute.toString().padLeft(2,'0')}'
: r.fecha;
return Card(margin: const EdgeInsets.only(bottom: 8),
child: Padding(padding: const EdgeInsets.all(12), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
CircleAvatar(backgroundColor: AppColors.guindaPrimary.withOpacity(0.1), radius: 18,
child: Text('${r.estrellas}', style: const TextStyle(fontSize: 11))),
const SizedBox(width: 10),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(r.nombreUsuario, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
Text('${r.colonia}${r.routeId}',
style: const TextStyle(color: AppColors.grisTexto, fontSize: 11)),
])),
Text(fechaStr, style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
]),
const SizedBox(height: 8),
Row(children: List.generate(5, (j) =>
Icon(j < r.estrellas ? Icons.star : Icons.star_border,
color: Colors.amber, size: 16))),
if (r.comentario.isNotEmpty && r.comentario != 'Sin comentario') ...[
const SizedBox(height: 6),
Text('"${r.comentario}"',
style: const TextStyle(fontSize: 12, fontStyle: FontStyle.italic,
color: AppColors.negroTexto)),
],
])));
});
}
}