Files
AppRecoleccion/lib/screens/admin/admin_dashboard_screen.dart
2026-05-23 08:36:15 -06:00

1377 lines
69 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 'dart:io';
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 'admin_reporte_detalle_screen.dart';
import 'admin_stats_screen.dart';
import 'manage_conductors_screen.dart';
import 'export_pdf_screen.dart';
import '../../screens/settings_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), // 0 Panel
_AdminMapTab(sim:sim), // 1 Mapa
_AdminReportesTab(), // 2 Reportes
_AdminAssignmentsTab(), // 3 Asignar
_AdminAlertasTab(sim:sim), // 4 Alertas
_AdminRoutesTab(), // 5 Rutas
_AdminReviewsTab(), // 6 Reseñas
];
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.guindaPrimary.withOpacity(0.15),
destinations:const[
NavigationDestination(icon:Icon(Icons.dashboard_outlined),
selectedIcon:Icon(Icons.dashboard,color:AppColors.guindaPrimary),label:'Panel'),
NavigationDestination(icon:Icon(Icons.map_outlined),
selectedIcon:Icon(Icons.map,color:AppColors.guindaPrimary),label:'Mapa'),
NavigationDestination(icon:Icon(Icons.report_outlined),
selectedIcon:Icon(Icons.report,color:AppColors.guindaPrimary),label:'Reportes'),
NavigationDestination(icon:Icon(Icons.people_alt_outlined),
selectedIcon:Icon(Icons.people_alt,color:AppColors.guindaPrimary),label:'Asignar'),
NavigationDestination(icon:Icon(Icons.warning_outlined),
selectedIcon:Icon(Icons.warning,color:AppColors.guindaPrimary),label:'Alertas'),
NavigationDestination(icon:Icon(Icons.route_outlined),
selectedIcon:Icon(Icons.route,color:AppColors.guindaPrimary),label:'Rutas'),
NavigationDestination(icon:Icon(Icons.star_outline),
selectedIcon:Icon(Icons.star,color:AppColors.guindaPrimary),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.guindaPrimary, 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.picture_as_pdf), tooltip: 'Exportar PDF',
onPressed: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => const ExportPdfScreen()))),
IconButton(icon: const Icon(Icons.bar_chart), tooltip: 'Estadisticas',
onPressed: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => const AdminStatsScreen()))),
IconButton(icon: const Icon(Icons.settings_outlined), tooltip: 'Configuracion',
onPressed: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => const SettingsScreen()))),
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.guindaPrimary),
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.guindaPrimary)),
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.guindaPrimary),
const SizedBox(width: 4),
const Text('Incidentes del conductor:',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 11,
color: AppColors.guindaPrimary)),
]),
),
...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.guindaPrimary,
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.guindaPrimary,
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.guindaPrimary,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 ────────────────────────────────────────────
// ── TAB 2: Reportes ciudadanos ───────────────────────────────────────────
class _AdminReportesTab extends StatefulWidget {
@override State<_AdminReportesTab> createState() => _AdminReportesTabState();
}
class _AdminReportesTabState extends State<_AdminReportesTab> {
List<Map<String, dynamic>> _reportes = [];
bool _loading = true;
String _filtroEstado = 'TODOS';
static const _estados = ['TODOS','PENDIENTE','EN_REVISION','EN_PROCESO','RESUELTO','COMPLETADO'];
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final r = await DbHelper.getReportesConUsuario();
if (mounted) setState(() { _reportes = r; _loading = false; });
}
List<Map<String,dynamic>> get _filtered => _filtroEstado == 'TODOS'
? _reportes
: _reportes.where((r) => (r['estado'] as String?) == _filtroEstado).toList();
Color _estadoColor(String s) {
switch(s) {
case 'COMPLETADO': return AppColors.verdeExito;
case 'RESUELTO': return Colors.teal;
case 'EN_PROCESO': return AppColors.azulInfo;
case 'EN_REVISION': return AppColors.naranjaAlerta;
default: return AppColors.grisTexto;
}
}
String _estadoLabel(String s) => s.replaceAll('_', ' ');
String _folio(int id) => 'RPT-${id.toString().padLeft(5, "0")}';
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(automaticallyImplyLeading: false,
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
title: Text('Reportes Ciudadanos (${_filtered.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: Column(children: [
Container(color: Colors.white, height: 44,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
itemCount: _estados.length,
itemBuilder: (_, i) {
final e = _estados[i];
final sel = _filtroEstado == e;
return Padding(padding: const EdgeInsets.only(right: 6),
child: FilterChip(
label: Text(e == 'TODOS' ? 'Todos' : _estadoLabel(e),
style: TextStyle(fontSize: 11,
color: sel ? Colors.white : AppColors.negroTexto)),
selected: sel,
selectedColor: e == 'TODOS' ? AppColors.guindaPrimary : _estadoColor(e),
checkmarkColor: Colors.white,
onSelected: (_) => setState(() => _filtroEstado = e)));
})),
Expanded(child: _loading
? const Center(child: CircularProgressIndicator())
: _filtered.isEmpty
? const Center(child: Text('Sin reportes',
style: TextStyle(color: AppColors.grisTexto)))
: RefreshIndicator(onRefresh: _load,
child: ListView.builder(
padding: const EdgeInsets.all(10),
itemCount: _filtered.length,
itemBuilder: (ctx, i) {
final r = _filtered[i];
final estado = r['estado'] as String? ?? 'PENDIENTE';
final id = r['id'] as int? ?? 0;
final folio = _folio(id);
final fotoPath = r['foto_path'] as String?;
final nombre = r['user_nombre'] as String? ?? 'Ciudadano';
final colonia = r['colonia'] as String? ?? '';
final desc = r['descripcion'] as String? ?? '';
return Card(margin: const EdgeInsets.only(bottom: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10),
side: BorderSide(color: _estadoColor(estado).withOpacity(0.3))),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () async {
await Navigator.push(ctx, MaterialPageRoute(
builder: (_) => AdminReporteDetalleScreen(reporte: r)));
_load();
},
child: Padding(padding: const EdgeInsets.all(12), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Text(folio, style: const TextStyle(fontWeight: FontWeight.bold,
fontSize: 13, color: AppColors.guindaPrimary)),
const SizedBox(width: 8),
Container(padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: _estadoColor(estado).withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: _estadoColor(estado).withOpacity(0.4))),
child: Text(_estadoLabel(estado),
style: TextStyle(fontSize: 9, fontWeight: FontWeight.bold,
color: _estadoColor(estado)))),
const Spacer(),
if (fotoPath != null && fotoPath.isNotEmpty)
const Icon(Icons.photo_camera, size: 14, color: AppColors.azulInfo),
const SizedBox(width: 4),
const Icon(Icons.chevron_right, color: AppColors.grisTexto, size: 18),
]),
const SizedBox(height: 4),
Text('$nombre$colonia',
style: const TextStyle(color: AppColors.grisTexto, fontSize: 11)),
const SizedBox(height: 2),
Text(desc, style: const TextStyle(fontSize: 12),
maxLines: 2, overflow: TextOverflow.ellipsis),
]))));
}))),
]),
);
}
class _AdminConductoresTab extends StatelessWidget {
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(automaticallyImplyLeading: false,
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
title: const Text('Gestión de Conductores'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
actions: [
IconButton(icon: const Icon(Icons.open_in_full),
tooltip: 'Ver en pantalla completa',
onPressed: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => const ManageConductorsScreen()))),
]),
body: const ManageConductorsScreen());
}
// ── 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.guindaPrimary, 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.guindaPrimary,
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.guindaPrimary.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.guindaPrimary))),
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.guindaPrimary.withOpacity(0.1),
borderRadius: BorderRadius.circular(8)),
child: Text(c, style: const TextStyle(fontSize: 10,
color: AppColors.guindaPrimary)))).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.guindaPrimary, 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)),
],
])));
});
}
}
// ── TAB 3: 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 = [];
static const _grupoA = ['LUNES', 'MIERCOLES', 'VIERNES'];
static const _grupoB = ['MARTES', 'JUEVES', 'SABADO'];
List<String> _todasLasRutas = [];
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final c = await DbHelper.getUsersByRol('CONDUCTOR');
// Combinar rutas hardcoded + rutas creadas por admin en DB
final dbRoutes = await DbHelper.getAllRouteDefinitions();
final dbIds = dbRoutes.map((r) => r.routeId).toList();
final staticIds = routesData.map((r) => r.routeId).toList();
// Unión sin duplicados
final allIds = {...staticIds, ...dbIds}.toList()..sort();
if (mounted) setState(() { _conductores = c; _todasLasRutas = allIds; });
}
Future<void> _loadAsigs(int id) async {
final a = await DbHelper.getAsignacionesByConductor(id);
if (mounted) setState(() => _asigs = a);
}
AssignmentModel? _getGrupo(List<String> dias) {
for (final dia in dias) {
try { return _asigs.firstWhere((a) => a.diaSemana == dia); } catch (_) {}
}
return null;
}
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!);
}
Future<void> _showNuevoConductor(BuildContext ctx) async {
final nombreCtrl = TextEditingController();
final emailCtrl = TextEditingController();
final passCtrl = TextEditingController();
bool obscure = true;
await showDialog(context: ctx, builder: (dCtx) => StatefulBuilder(
builder: (dCtx, setSt) => AlertDialog(
title: const Row(children: [
Icon(Icons.person_add, color: AppColors.guindaPrimary),
SizedBox(width: 8),
Text('Nuevo Conductor'),
]),
content: SingleChildScrollView(child: Column(mainAxisSize: MainAxisSize.min, children: [
TextField(controller: nombreCtrl,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(labelText: 'Nombre completo',
prefixIcon: Icon(Icons.person_outline), border: OutlineInputBorder())),
const SizedBox(height: 10),
TextField(controller: emailCtrl,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(labelText: 'Correo electronico',
prefixIcon: Icon(Icons.email_outlined), border: OutlineInputBorder())),
const SizedBox(height: 10),
TextField(controller: passCtrl, obscureText: obscure,
decoration: InputDecoration(labelText: 'Contrasena',
prefixIcon: const Icon(Icons.lock_outline),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(obscure ? Icons.visibility_off : Icons.visibility),
onPressed: () => setSt(() => obscure = !obscure)))),
])),
actions: [
TextButton(
onPressed: () => Navigator.pop(dCtx),
child: const Text('Cancelar')),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.guindaPrimary,
foregroundColor: Colors.white),
onPressed: () async {
final nombre = nombreCtrl.text.trim();
final email = emailCtrl.text.trim().toLowerCase();
final pass = passCtrl.text;
if (nombre.isEmpty || email.isEmpty) {
ScaffoldMessenger.of(dCtx).showSnackBar(const SnackBar(
content: Text('Completa nombre y correo'),
backgroundColor: AppColors.rojoError));
return;
}
if (pass.length < 6) {
ScaffoldMessenger.of(dCtx).showSnackBar(const SnackBar(
content: Text('La contrasena debe tener minimo 6 caracteres'),
backgroundColor: AppColors.rojoError));
return;
}
Navigator.pop(dCtx);
await Future.delayed(const Duration(milliseconds: 100));
try {
final uid = await DbHelper.insertConductor(nombre, email, pass);
await _load();
if (mounted) {
final idx = _conductores.indexWhere((c) => c.id == uid);
if (idx >= 0) {
setState(() => _sel = _conductores[idx]);
await _loadAsigs(_conductores[idx].id!);
}
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(nombre + ' creado. Asignale una ruta abajo.'),
backgroundColor: AppColors.verdeExito,
duration: const Duration(seconds: 3)));
}
} catch (e) {
if (mounted) {
final msg = e.toString().contains('UNIQUE')
? 'Ese correo ya esta registrado'
: 'Error: ' + e.toString();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(msg), backgroundColor: AppColors.rojoError));
}
}
},
child: const Text('Crear Conductor')),
])));
nombreCtrl.dispose();
emailCtrl.dispose();
passCtrl.dispose();
}
Future<void> _showEditarConductor(BuildContext ctx, UserModel conductor) async {
final nombreCtrl = TextEditingController(text: conductor.nombre);
final emailCtrl = TextEditingController(text: conductor.email);
await showDialog(context: ctx, builder: (dCtx) => AlertDialog(
title: const Row(children: [
Icon(Icons.edit, color: AppColors.guindaPrimary),
SizedBox(width: 8),
Text('Editar Conductor'),
]),
content: Column(mainAxisSize: MainAxisSize.min, children: [
TextField(controller: nombreCtrl, textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(labelText: 'Nombre completo',
prefixIcon: Icon(Icons.person_outline), border: OutlineInputBorder())),
const SizedBox(height: 10),
TextField(controller: emailCtrl, keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(labelText: 'Correo electronico',
prefixIcon: Icon(Icons.email_outlined), border: OutlineInputBorder())),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(dCtx), child: const Text('Cancelar')),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(backgroundColor: AppColors.guindaPrimary,
foregroundColor: Colors.white),
onPressed: () async {
await DbHelper.updateConductor(conductor.id!,
nombreCtrl.text.trim(), emailCtrl.text.trim().toLowerCase());
if (dCtx.mounted) Navigator.pop(dCtx);
await _load();
},
icon: const Icon(Icons.save, size: 16),
label: const Text('Guardar')),
]));
nombreCtrl.dispose(); emailCtrl.dispose();
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(automaticallyImplyLeading: false,
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
title: const Text('Conductores y Asignaciones'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
actions: [
IconButton(
icon: const Icon(Icons.person_add_outlined),
tooltip: 'Nuevo conductor',
onPressed: () => _showNuevoConductor(context)),
IconButton(icon: const Icon(Icons.refresh), onPressed: _load),
]),
body: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(children: [
// Lista de conductores con chip seleccionable
if (_conductores.isEmpty)
Container(padding: const EdgeInsets.all(16), margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.shade200)),
child: Row(children: [
const Icon(Icons.info_outline, color: AppColors.naranjaAlerta),
const SizedBox(width: 8),
const Expanded(child: Text('No hay conductores registrados. Agrega uno con el boton +',
style: TextStyle(fontSize: 12, color: AppColors.naranjaAlerta))),
]))
else ...[
const Align(alignment: Alignment.centerLeft,
child: Text('Conductores registrados',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13,
color: AppColors.guindaPrimary))),
const SizedBox(height: 8),
SizedBox(height: 44, child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: _conductores.length,
itemBuilder: (_, i) {
final c = _conductores[i];
final sel = _sel?.id == c.id;
return Padding(padding: const EdgeInsets.only(right: 8),
child: InkWell(
borderRadius: BorderRadius.circular(22),
onTap: () { setState(() => _sel = c); _loadAsigs(c.id!); },
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: sel ? AppColors.guindaPrimary : Colors.white,
borderRadius: BorderRadius.circular(22),
border: Border.all(
color: sel ? AppColors.guindaPrimary : Colors.grey.shade300,
width: sel ? 2 : 1),
boxShadow: sel ? [BoxShadow(color: AppColors.guindaPrimary.withOpacity(0.3),
blurRadius: 6, offset: const Offset(0, 2))] : [],
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
CircleAvatar(radius: 12,
backgroundColor: sel ? Colors.white.withOpacity(0.3) : AppColors.guindaPrimary.withOpacity(0.1),
child: Text(c.nombre[0].toUpperCase(),
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold,
color: sel ? Colors.white : AppColors.guindaPrimary))),
const SizedBox(width: 6),
Text(c.nombre.split(' ').first,
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600,
color: sel ? Colors.white : AppColors.negroTexto)),
]),
)));
})),
const SizedBox(height: 16),
],
// Info del conductor seleccionado
if (_sel != null) ...[
Container(padding: const EdgeInsets.all(10), margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(color: AppColors.guindaPrimary.withOpacity(0.06),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.guindaPrimary.withOpacity(0.2))),
child: Row(children: [
CircleAvatar(radius: 18, backgroundColor: AppColors.guindaPrimary,
child: Text(_sel!.nombre[0].toUpperCase(),
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold))),
const SizedBox(width: 10),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(_sel!.nombre, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
Text(_sel!.email, style: const TextStyle(color: AppColors.grisTexto, fontSize: 11)),
])),
TextButton.icon(
onPressed: () => _showEditarConductor(context, _sel!),
icon: const Icon(Icons.edit_outlined, size: 14),
label: const Text('Editar', style: TextStyle(fontSize: 11)),
style: TextButton.styleFrom(foregroundColor: AppColors.guindaPrimary)),
])),
],
// Info de grupos
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 un bloque:\n'
'Grupo A — Lunes, Miercoles y Viernes\n'
'Grupo B — Martes, Jueves y Sabado',
style: TextStyle(fontSize: 12, color: AppColors.azulInfo))),
if (_sel != null) ...[
const SizedBox(height: 20),
_GrupoRow(label: 'Grupo A — Lunes, Miercoles y Viernes',
icon: Icons.wb_sunny_outlined, color: Colors.blue,
current: _getGrupo(_grupoA), routeIds: _todasLasRutas,
onSave: (rid, turno) => _saveGrupo(_grupoA, rid, turno)),
const SizedBox(height: 12),
_GrupoRow(label: 'Grupo B — Martes, Jueves y Sabado',
icon: Icons.wb_twilight, color: Colors.deepPurple,
current: _getGrupo(_grupoB), routeIds: _todasLasRutas,
onSave: (rid, turno) => _saveGrupo(_grupoB, rid, turno)),
if (_asigs.isNotEmpty) ...[
const SizedBox(height: 20),
const Text('Resumen actual', style: TextStyle(fontWeight: FontWeight.bold,
color: AppColors.guindaPrimary, 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.guindaPrimary.withOpacity(0.1),
borderRadius: BorderRadius.circular(10)),
child: Text('${a.routeId}${a.turno}',
style: const TextStyle(fontSize: 11, color: AppColors.guindaPrimary))),
]))),
]))),
],
],
])),
);
}
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: 130, 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)),
]),
])));
}
// ── TAB 4: Alertas del sistema ────────────────────────────────────────────
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 _loading = true;
String _filtro = 'TODAS';
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final a = await DbHelper.getAlertas();
if (mounted) setState(() { _alertas = a; _loading = false; });
}
List<AlertaModel> get _filtered {
if (_filtro == 'TODAS') return _alertas;
if (_filtro == 'ACTIVAS') return _alertas.where((a) => !a.resuelta).toList();
return _alertas.where((a) => a.resuelta).toList();
}
Color _color(String tipo) {
if (tipo.startsWith('INCIDENTE')) return AppColors.naranjaAlerta;
if (tipo == 'GPS_PERDIDO') return AppColors.rojoError;
if (tipo == 'CAMION_DETENIDO') return AppColors.naranjaAlerta;
if (tipo.startsWith('RUTA_')) return AppColors.guindaPrimary;
return AppColors.azulInfo;
}
IconData _icon(String tipo) {
if (tipo.startsWith('INCIDENTE')) return Icons.build;
if (tipo == 'GPS_PERDIDO') return Icons.gps_off;
if (tipo == 'CAMION_DETENIDO') return Icons.timer_off;
if (tipo == 'RUTA_CANCELADA') return Icons.cancel;
if (tipo == 'RUTA_RETRASADA') return Icons.access_time;
return Icons.warning_amber;
}
Future<void> _showAcciones(AlertaModel a) async {
showModalBottomSheet(context: context, shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (_) => SafeArea(child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(margin: const EdgeInsets.symmetric(vertical: 8),
width: 40, height: 4,
decoration: BoxDecoration(color: Colors.grey.shade300, borderRadius: BorderRadius.circular(2))),
Padding(padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Text(a.tipo.replaceAll('_', ' '),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15))),
Padding(padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(a.mensaje, style: const TextStyle(color: AppColors.grisTexto, fontSize: 12))),
const Divider(),
ListTile(
leading: const CircleAvatar(radius: 18,
backgroundColor: Color(0xFFE8F5E9),
child: Icon(Icons.check_circle, color: AppColors.verdeExito, size: 20)),
title: const Text('Marcar como resuelta',
style: TextStyle(fontWeight: FontWeight.w600)),
subtitle: const Text('La alerta se archivará',
style: TextStyle(fontSize: 11)),
onTap: () async {
Navigator.pop(context);
await DbHelper.resolverAlerta(a.id!);
await _load();
}),
if (a.tipo.startsWith('INCIDENTE'))
ListTile(
leading: CircleAvatar(radius: 18,
backgroundColor: AppColors.guindaPrimary.withOpacity(0.1),
child: const Icon(Icons.route, color: AppColors.guindaPrimary, size: 20)),
title: const Text('Ver ruta afectada',
style: TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text('Ruta: ${a.routeId}',
style: const TextStyle(fontSize: 11)),
onTap: () => Navigator.pop(context)),
if (a.tipo == 'GPS_PERDIDO' || a.tipo == 'CAMION_DETENIDO')
ListTile(
leading: const CircleAvatar(radius: 18,
backgroundColor: Color(0xFFFFF3E0),
child: Icon(Icons.info_outline, color: AppColors.naranjaAlerta, size: 20)),
title: const Text('Ignorar temporalmente',
style: TextStyle(fontWeight: FontWeight.w600)),
subtitle: const Text('No se tomará acción ahora',
style: TextStyle(fontSize: 11)),
onTap: () => Navigator.pop(context)),
ListTile(
leading: const CircleAvatar(radius: 18,
backgroundColor: Color(0xFFFFEBEE),
child: Icon(Icons.delete_outline, color: AppColors.rojoError, size: 20)),
title: const Text('Eliminar alerta',
style: TextStyle(fontWeight: FontWeight.w600, color: AppColors.rojoError)),
subtitle: const Text('Se borrará permanentemente',
style: TextStyle(fontSize: 11)),
onTap: () async {
Navigator.pop(context);
await DbHelper.resolverAlerta(a.id!);
await _load();
}),
const SizedBox(height: 8),
])));
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(automaticallyImplyLeading: false,
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
title: Text('Alertas del Sistema (${_filtered.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: Column(children: [
// Filtros
Container(color: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(children: ['TODAS','ACTIVAS','RESUELTAS'].map((f) {
final sel = _filtro == f;
return Padding(padding: const EdgeInsets.only(right: 8),
child: FilterChip(
label: Text(f, style: TextStyle(fontSize: 11,
color: sel ? Colors.white : AppColors.negroTexto)),
selected: sel,
selectedColor: AppColors.guindaPrimary,
checkmarkColor: Colors.white,
onSelected: (_) => setState(() => _filtro = f)));
}).toList())),
// Lista
Expanded(child: _loading
? const Center(child: CircularProgressIndicator(color: AppColors.guindaPrimary))
: _filtered.isEmpty
? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
const Icon(Icons.notifications_none, color: AppColors.grisTexto, size: 48),
const SizedBox(height: 12),
Text(_filtro == 'ACTIVAS' ? 'Sin alertas activas' : 'Sin alertas',
style: const TextStyle(color: AppColors.grisTexto)),
]))
: ListView.builder(
padding: const EdgeInsets.all(10),
itemCount: _filtered.length,
itemBuilder: (_, i) {
final a = _filtered[i];
final c = _color(a.tipo);
final fecha = DateTime.tryParse(a.fecha);
final fechaStr = fecha != null
? '${fecha.day}/${fecha.month} ${fecha.hour.toString().padLeft(2,'0')}:${fecha.minute.toString().padLeft(2,'0')}'
: '';
return Card(margin: const EdgeInsets.only(bottom: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10),
side: BorderSide(color: a.resuelta
? Colors.grey.shade200 : c.withOpacity(0.3))),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: a.resuelta ? null : () => _showAcciones(a),
child: Padding(padding: const EdgeInsets.all(12), child: Row(children: [
CircleAvatar(radius: 20,
backgroundColor: a.resuelta
? Colors.grey.shade100 : c.withOpacity(0.12),
child: Icon(_icon(a.tipo),
color: a.resuelta ? AppColors.grisTexto : c, size: 18)),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(a.tipo.replaceAll('_', ' '),
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold,
color: a.resuelta ? AppColors.grisTexto : c)),
const SizedBox(height: 2),
Text(a.mensaje, maxLines: 2, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11, color: AppColors.grisTexto)),
const SizedBox(height: 2),
Text('${a.routeId}$fechaStr',
style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
])),
const SizedBox(width: 8),
if (a.resuelta)
const Icon(Icons.check_circle, color: AppColors.verdeExito, size: 20)
else
const Icon(Icons.chevron_right, color: AppColors.grisTexto, size: 20),
]))));
})),
]),
);
}
// ── Widgets auxiliares ────────────────────────────────────────────────────
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: [
CircleAvatar(radius: 20, backgroundColor: color.withOpacity(0.12),
child: Icon(icon, color: color, size: 20)),
const SizedBox(width: 10),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(value, style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: color)),
Text(label, style: const TextStyle(fontSize: 10, 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: AppColors.guindaPrimary, borderRadius: BorderRadius.circular(10),
boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 6)]),
child: Padding(padding: const EdgeInsets.all(10), child: Row(children: [
const Icon(Icons.notifications, color: Colors.white, size: 20),
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: 12)),
Text(notif.body, style: const TextStyle(color: Colors.white70, fontSize: 10),
maxLines: 1, overflow: TextOverflow.ellipsis),
])),
IconButton(icon: const Icon(Icons.close, color: Colors.white, size: 16),
onPressed: onDismiss),
]))));
}