1377 lines
69 KiB
Dart
1377 lines
69 KiB
Dart
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),
|
||
]))));
|
||
}
|