Actualizacion de mejoras

This commit is contained in:
2026-05-23 08:36:15 -06:00
parent ebce0badde
commit 8fe3665ffb
11 changed files with 738 additions and 291 deletions

View File

@@ -49,22 +49,22 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
selectedIndex:_tab,
onDestinationSelected:(i)=>setState(()=>_tab=i),
backgroundColor:Colors.white,
indicatorColor:AppColors.verdeAdmin.withOpacity(0.15),
indicatorColor:AppColors.guindaPrimary.withOpacity(0.15),
destinations:const[
NavigationDestination(icon:Icon(Icons.dashboard_outlined),
selectedIcon:Icon(Icons.dashboard,color:AppColors.verdeAdmin),label:'Panel'),
selectedIcon:Icon(Icons.dashboard,color:AppColors.guindaPrimary),label:'Panel'),
NavigationDestination(icon:Icon(Icons.map_outlined),
selectedIcon:Icon(Icons.map,color:AppColors.verdeAdmin),label:'Mapa'),
selectedIcon:Icon(Icons.map,color:AppColors.guindaPrimary),label:'Mapa'),
NavigationDestination(icon:Icon(Icons.report_outlined),
selectedIcon:Icon(Icons.report,color:AppColors.verdeAdmin),label:'Reportes'),
selectedIcon:Icon(Icons.report,color:AppColors.guindaPrimary),label:'Reportes'),
NavigationDestination(icon:Icon(Icons.people_alt_outlined),
selectedIcon:Icon(Icons.people_alt,color:AppColors.verdeAdmin),label:'Asignar'),
selectedIcon:Icon(Icons.people_alt,color:AppColors.guindaPrimary),label:'Asignar'),
NavigationDestination(icon:Icon(Icons.warning_outlined),
selectedIcon:Icon(Icons.warning,color:AppColors.verdeAdmin),label:'Alertas'),
selectedIcon:Icon(Icons.warning,color:AppColors.guindaPrimary),label:'Alertas'),
NavigationDestination(icon:Icon(Icons.route_outlined),
selectedIcon:Icon(Icons.route,color:AppColors.verdeAdmin),label:'Rutas'),
selectedIcon:Icon(Icons.route,color:AppColors.guindaPrimary),label:'Rutas'),
NavigationDestination(icon:Icon(Icons.star_outline),
selectedIcon:Icon(Icons.star,color:AppColors.verdeAdmin),label:'Reseñas'),
selectedIcon:Icon(Icons.star,color:AppColors.guindaPrimary),label:'Reseñas'),
],
),
);
@@ -137,7 +137,7 @@ class _AdminHomeTabState extends State<_AdminHomeTab> {
@override
Widget build(BuildContext context) {
return CustomScrollView(slivers: [
SliverAppBar(pinned: true, backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
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)),
@@ -159,14 +159,14 @@ class _AdminHomeTabState extends State<_AdminHomeTab> {
),
SliverPadding(padding: const EdgeInsets.all(12), sliver: SliverList(delegate: SliverChildListDelegate([
Row(children: [
_Stat('Rutas', '${routesData.length}', Icons.local_shipping, AppColors.verdeAdmin),
_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.verdeAdmin)),
fontSize: 16, color: AppColors.guindaPrimary)),
const SizedBox(height: 8),
...routesData.map((r) {
final status = _getStatus(r.routeId);
@@ -249,18 +249,18 @@ class _AdminHomeTabState extends State<_AdminHomeTab> {
Padding(
padding: const EdgeInsets.fromLTRB(14, 6, 14, 2),
child: Row(children: [
const Icon(Icons.build, size: 13, color: AppColors.moradoConductor),
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.moradoConductor)),
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.moradoConductor,
decoration: const BoxDecoration(color: AppColors.guindaPrimary,
shape: BoxShape.circle)),
const SizedBox(width: 6),
Expanded(child: Text(inc.mensaje,
@@ -286,7 +286,7 @@ class _AdminHomeTabState extends State<_AdminHomeTab> {
}
},
style: TextButton.styleFrom(
foregroundColor: AppColors.verdeAdmin,
foregroundColor: AppColors.guindaPrimary,
padding: const EdgeInsets.symmetric(horizontal: 8)),
child: const Text('Actuar', style: TextStyle(fontSize: 10)),
),
@@ -396,7 +396,7 @@ class _AdminMapTab extends StatelessWidget {
@override
Widget build(BuildContext context) => Scaffold(
appBar:AppBar(automaticallyImplyLeading:false,
backgroundColor:AppColors.verdeAdmin,foregroundColor:Colors.white,
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))),
@@ -444,7 +444,7 @@ class _AdminReportesTabState extends State<_AdminReportesTab> {
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(automaticallyImplyLeading: false,
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
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)),
@@ -464,7 +464,7 @@ class _AdminReportesTabState extends State<_AdminReportesTab> {
style: TextStyle(fontSize: 11,
color: sel ? Colors.white : AppColors.negroTexto)),
selected: sel,
selectedColor: e == 'TODOS' ? AppColors.verdeAdmin : _estadoColor(e),
selectedColor: e == 'TODOS' ? AppColors.guindaPrimary : _estadoColor(e),
checkmarkColor: Colors.white,
onSelected: (_) => setState(() => _filtroEstado = e)));
})),
@@ -500,7 +500,7 @@ class _AdminReportesTabState extends State<_AdminReportesTab> {
crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Text(folio, style: const TextStyle(fontWeight: FontWeight.bold,
fontSize: 13, color: AppColors.verdeAdmin)),
fontSize: 13, color: AppColors.guindaPrimary)),
const SizedBox(width: 8),
Container(padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
@@ -533,7 +533,7 @@ class _AdminConductoresTab extends StatelessWidget {
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(automaticallyImplyLeading: false,
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
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)),
@@ -565,7 +565,7 @@ class _AdminRoutesTabState extends State<_AdminRoutesTab> {
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(automaticallyImplyLeading: false,
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
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)),
@@ -589,7 +589,7 @@ class _AdminRoutesTabState extends State<_AdminRoutesTab> {
const Text('No hay rutas creadas', style: TextStyle(color: AppColors.grisTexto)),
const SizedBox(height: 16),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(backgroundColor: AppColors.verdeAdmin,
style: ElevatedButton.styleFrom(backgroundColor: AppColors.guindaPrimary,
foregroundColor: Colors.white),
onPressed: () async {
final ok = await Navigator.push(context, MaterialPageRoute(
@@ -607,13 +607,13 @@ class _AdminRoutesTabState extends State<_AdminRoutesTab> {
: r.turno == 'VESPERTINO' ? '🌅' : '🌙';
return Card(margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10),
side: BorderSide(color: AppColors.verdeAdmin.withOpacity(0.3))),
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.verdeAdmin))),
fontSize: 14, color: AppColors.guindaPrimary))),
IconButton(icon: const Icon(Icons.edit_outlined, size: 18),
onPressed: () async {
final ok = await Navigator.push(context, MaterialPageRoute(
@@ -636,10 +636,10 @@ class _AdminRoutesTabState extends State<_AdminRoutesTab> {
const SizedBox(height: 4),
Wrap(spacing: 4, runSpacing: 4, children: r.colonias.take(8).map((c) =>
Container(padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(color: AppColors.verdeAdmin.withOpacity(0.1),
decoration: BoxDecoration(color: AppColors.guindaPrimary.withOpacity(0.1),
borderRadius: BorderRadius.circular(8)),
child: Text(c, style: const TextStyle(fontSize: 10,
color: AppColors.verdeAdmin)))).toList()),
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)),
@@ -670,7 +670,7 @@ class _AdminReviewsTabState extends State<_AdminReviewsTab> {
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(automaticallyImplyLeading: false,
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
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)),
@@ -794,11 +794,19 @@ class _AdminAssignmentsTabState extends State<_AdminAssignmentsTab> {
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');
if (mounted) setState(() => _conductores = c);
// 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 {
@@ -821,14 +829,227 @@ class _AdminAssignmentsTabState extends State<_AdminAssignmentsTab> {
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.verdeAdmin, foregroundColor: Colors.white,
title: const Text('Asignar Rutas a Conductores'),
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))),
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)),
@@ -837,29 +1058,21 @@ class _AdminAssignmentsTabState extends State<_AdminAssignmentsTab> {
'Grupo A — Lunes, Miercoles y Viernes\n'
'Grupo B — Martes, Jueves y Sabado',
style: TextStyle(fontSize: 12, color: AppColors.azulInfo))),
DropdownButtonFormField<UserModel>(
decoration: const InputDecoration(labelText: 'Selecciona conductor',
border: OutlineInputBorder(), filled: true, fillColor: Colors.white),
hint: const Text('Conductor...'),
value: _sel,
items: _conductores.map((c) => DropdownMenuItem(value: c,
child: Text(c.nombre, style: const TextStyle(fontSize: 13)))).toList(),
onChanged: (c) { setState(() => _sel = c); if (c != null) _loadAsigs(c.id!); }),
if (_sel != null) ...[
const SizedBox(height: 20),
_GrupoRow(label: 'Grupo A — Lunes, Miercoles y Viernes',
icon: Icons.wb_sunny_outlined, color: Colors.blue,
current: _getGrupo(_grupoA), routeIds: routesData.map((r) => r.routeId).toList(),
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: routesData.map((r) => r.routeId).toList(),
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.verdeAdmin, fontSize: 14)),
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),
@@ -867,10 +1080,10 @@ class _AdminAssignmentsTabState extends State<_AdminAssignmentsTab> {
SizedBox(width: 100, child: Text(AppDias.label(a.diaSemana),
style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 12))),
Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(color: AppColors.verdeAdmin.withOpacity(0.1),
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.verdeAdmin))),
style: const TextStyle(fontSize: 11, color: AppColors.guindaPrimary))),
]))),
]))),
],
@@ -947,6 +1160,7 @@ class _AdminAlertasTab extends StatefulWidget {
class _AdminAlertasTabState extends State<_AdminAlertasTab> {
List<AlertaModel> _alertas = [];
bool _loading = true;
String _filtro = 'TODAS';
@override void initState() { super.initState(); _load(); }
@@ -955,62 +1169,172 @@ class _AdminAlertasTabState extends State<_AdminAlertasTab> {
if (mounted) setState(() { _alertas = a; _loading = false; });
}
Color _alertaColor(String tipo) {
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.rojoError;
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.verdeAdmin, foregroundColor: Colors.white,
title: Text('Alertas del Sistema (${_alertas.length})'),
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: _loading
? const Center(child: CircularProgressIndicator())
: _alertas.isEmpty
? const Center(child: Text('Sin alertas',
style: TextStyle(color: AppColors.grisTexto)))
: ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: _alertas.length,
itemBuilder: (_, i) {
final a = _alertas[i];
final c = _alertaColor(a.tipo);
return Card(margin: const EdgeInsets.only(bottom: 6),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8),
side: BorderSide(color: c.withOpacity(0.3))),
child: ListTile(dense: true,
leading: CircleAvatar(radius: 18, backgroundColor: c.withOpacity(0.12),
child: Icon(a.resuelta ? Icons.check : Icons.warning,
color: a.resuelta ? AppColors.verdeExito : c, size: 16)),
title: Text(a.tipo.replaceAll('_', ' '),
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold,
color: a.resuelta ? AppColors.grisTexto : c)),
subtitle: Text(a.mensaje, maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11)),
trailing: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Text(a.routeId, style: const TextStyle(
fontSize: 10, color: AppColors.grisTexto)),
if (!a.resuelta) TextButton(
onPressed: () async {
await DbHelper.resolverAlerta(a.id!); _load();
},
style: TextButton.styleFrom(
padding: EdgeInsets.zero, minimumSize: Size.zero,
foregroundColor: AppColors.verdeExito),
child: const Text('Resolver', style: TextStyle(fontSize: 10))),
])));
}),
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;
@@ -1034,7 +1358,7 @@ class _AdminBanner extends StatelessWidget {
@override
Widget build(BuildContext context) => Material(color: Colors.transparent,
child: Container(margin: const EdgeInsets.all(10),
decoration: BoxDecoration(color: AppColors.verdeAdmin, borderRadius: BorderRadius.circular(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),

View File

@@ -110,7 +110,7 @@ class _AdminReporteDetalleScreenState extends State<AdminReporteDetalleScreen>
return Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
title: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Reporte $folio', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
Text(_r['colonia'] as String? ?? '', style: const TextStyle(fontSize: 11, color: Colors.white70)),
@@ -179,7 +179,7 @@ class _AdminReporteDetalleScreenState extends State<AdminReporteDetalleScreen>
// Cambiar estado
if (!isClosed) ...[
const Text('Cambiar Estado', style: TextStyle(fontWeight: FontWeight.bold,
color: AppColors.verdeAdmin, fontSize: 14)),
color: AppColors.guindaPrimary, fontSize: 14)),
const SizedBox(height: 8),
Wrap(spacing: 8, runSpacing: 8, children: _estados.map((e) {
final isActual = e == estado;
@@ -199,7 +199,7 @@ class _AdminReporteDetalleScreenState extends State<AdminReporteDetalleScreen>
Row(children: [
const Expanded(child: Text('Evidencias del Ayuntamiento',
style: TextStyle(fontWeight: FontWeight.bold,
color: AppColors.verdeAdmin, fontSize: 14))),
color: AppColors.guindaPrimary, fontSize: 14))),
Text('${_evidencias.length}',
style: const TextStyle(color: AppColors.grisTexto, fontSize: 12)),
]),
@@ -221,8 +221,8 @@ class _AdminReporteDetalleScreenState extends State<AdminReporteDetalleScreen>
onPressed: _pickFoto,
icon: const Icon(Icons.camera_alt, size: 16),
label: const Text('Tomar foto', style: TextStyle(fontSize: 12)),
style: OutlinedButton.styleFrom(foregroundColor: AppColors.verdeAdmin,
side: const BorderSide(color: AppColors.verdeAdmin)))
style: OutlinedButton.styleFrom(foregroundColor: AppColors.guindaPrimary,
side: const BorderSide(color: AppColors.guindaPrimary)))
: Stack(children: [
ClipRRect(borderRadius: BorderRadius.circular(6),
child: Image.file(_evidFoto!, height: 70, width: double.infinity, fit: BoxFit.cover)),
@@ -234,7 +234,7 @@ class _AdminReporteDetalleScreenState extends State<AdminReporteDetalleScreen>
const SizedBox(width: 8),
ElevatedButton(
onPressed: _loadingEv ? null : _agregarEvidencia,
style: ElevatedButton.styleFrom(backgroundColor: AppColors.verdeAdmin,
style: ElevatedButton.styleFrom(backgroundColor: AppColors.guindaPrimary,
foregroundColor: Colors.white, minimumSize: const Size(80, 42)),
child: _loadingEv
? const SizedBox(width: 16, height: 16,
@@ -249,11 +249,11 @@ class _AdminReporteDetalleScreenState extends State<AdminReporteDetalleScreen>
child: Padding(padding: const EdgeInsets.all(10), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
const Icon(Icons.verified, color: AppColors.verdeAdmin, size: 14),
const Icon(Icons.verified, color: AppColors.guindaPrimary, size: 14),
const SizedBox(width: 4),
Text(ev['admin_nombre'] as String? ?? 'Admin',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 11,
color: AppColors.verdeAdmin)),
color: AppColors.guindaPrimary)),
const Spacer(),
Text(_timeAgo(ev['fecha'] as String? ?? ''),
style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),

View File

@@ -31,7 +31,7 @@ class _AdminStatsScreenState extends State<AdminStatsScreen> {
Widget build(BuildContext context) => Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
title: const Text('Dashboard de Estadisticas'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
@@ -57,7 +57,7 @@ class _AdminStatsScreenState extends State<AdminStatsScreen> {
Icons.warning, AppColors.rojoError),
const SizedBox(width: 8),
_KpiCard('Conductores', '${_stats['total_conductores']}',
Icons.person, AppColors.moradoConductor),
Icons.person, AppColors.guindaPrimary),
]),
const SizedBox(height: 20),
@@ -93,10 +93,10 @@ class _AdminStatsScreenState extends State<AdminStatsScreen> {
FlSpot(e.key.toDouble(),
(e.value['promedio'] as num? ?? 0).toDouble().clamp(1.0, 5.0))).toList(),
isCurved: true,
color: AppColors.verdeAdmin,
color: AppColors.guindaPrimary,
barWidth: 3,
belowBarData: BarAreaData(show: true,
color: AppColors.verdeAdmin.withOpacity(0.1)),
color: AppColors.guindaPrimary.withOpacity(0.1)),
dotData: const FlDotData(show: true),
)],
))))),
@@ -247,7 +247,7 @@ class _SectionTitle extends StatelessWidget {
const _SectionTitle(this.title);
@override
Widget build(BuildContext context) => Text(title,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15, color: AppColors.verdeAdmin));
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15, color: AppColors.guindaPrimary));
}
class _Legend extends StatelessWidget {

View File

@@ -93,7 +93,7 @@ class _CreateRouteScreenState extends State<CreateRouteScreen> {
return Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
title: Text(widget.editing != null ? 'Editar Ruta' : 'Nueva Ruta'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
@@ -115,7 +115,7 @@ class _CreateRouteScreenState extends State<CreateRouteScreen> {
Expanded(child: RadioListTile<String>(dense: true, value: t,
groupValue: _turno,
title: Text(_turnoLabel(t), style: const TextStyle(fontSize: 12)),
activeColor: AppColors.verdeAdmin,
activeColor: AppColors.guindaPrimary,
onChanged: (v) => setState(() => _turno = v!)))
).toList()),
const SizedBox(height: 8),
@@ -150,16 +150,16 @@ class _CreateRouteScreenState extends State<CreateRouteScreen> {
Expanded(child: OutlinedButton(
onPressed: () => setState(() => _diasSeleccionados = List.from(_diasGrupoA)),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.verdeAdmin,
side: const BorderSide(color: AppColors.verdeAdmin)),
foregroundColor: AppColors.guindaPrimary,
side: const BorderSide(color: AppColors.guindaPrimary)),
child: const Text('Grupo A\nL/M/V', textAlign: TextAlign.center,
style: TextStyle(fontSize: 11)))),
const SizedBox(width: 8),
Expanded(child: OutlinedButton(
onPressed: () => setState(() => _diasSeleccionados = List.from(_diasGrupoB)),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.moradoConductor,
side: const BorderSide(color: AppColors.moradoConductor)),
foregroundColor: AppColors.guindaPrimary,
side: const BorderSide(color: AppColors.guindaPrimary)),
child: const Text('Grupo B\nM/J/S', textAlign: TextAlign.center,
style: TextStyle(fontSize: 11)))),
]),
@@ -170,7 +170,7 @@ class _CreateRouteScreenState extends State<CreateRouteScreen> {
label: Text(AppDias.label(dia), style: TextStyle(fontSize: 11,
color: sel ? Colors.white : AppColors.negroTexto)),
selected: sel,
selectedColor: AppColors.verdeAdmin,
selectedColor: AppColors.guindaPrimary,
checkmarkColor: Colors.white,
onSelected: (v) => setState(() {
if (v) _diasSeleccionados.add(dia);
@@ -202,7 +202,7 @@ class _CreateRouteScreenState extends State<CreateRouteScreen> {
return CheckboxListTile(dense: true,
title: Text(c, style: const TextStyle(fontSize: 12)),
value: sel,
activeColor: AppColors.verdeAdmin,
activeColor: AppColors.guindaPrimary,
controlAffinity: ListTileControlAffinity.leading,
onChanged: (v) => setState(() {
if (v == true) _coloniasSeleccionadas.add(c);
@@ -216,8 +216,8 @@ class _CreateRouteScreenState extends State<CreateRouteScreen> {
const SizedBox(height: 8),
Wrap(spacing: 4, runSpacing: 4, children: _coloniasSeleccionadas.map((c) =>
Chip(label: Text(c, style: const TextStyle(fontSize: 10)),
backgroundColor: AppColors.verdeAdmin.withOpacity(0.1),
deleteIconColor: AppColors.verdeAdmin,
backgroundColor: AppColors.guindaPrimary.withOpacity(0.1),
deleteIconColor: AppColors.guindaPrimary,
onDeleted: () => setState(() => _coloniasSeleccionadas.remove(c)))).toList()),
],
const SizedBox(height: 24),
@@ -226,7 +226,7 @@ class _CreateRouteScreenState extends State<CreateRouteScreen> {
child: ElevatedButton.icon(
onPressed: _loading ? null : _guardar,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
icon: _loading
? const SizedBox(width: 18, height: 18,
@@ -242,12 +242,12 @@ class _CreateRouteScreenState extends State<CreateRouteScreen> {
Widget _section(String title) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(title, style: const TextStyle(fontWeight: FontWeight.bold,
color: AppColors.verdeAdmin, fontSize: 15)));
color: AppColors.guindaPrimary, fontSize: 15)));
Widget _field(TextEditingController ctrl, String label, IconData icon) =>
TextField(controller: ctrl,
decoration: InputDecoration(labelText: label,
prefixIcon: Icon(icon, color: AppColors.verdeAdmin),
prefixIcon: Icon(icon, color: AppColors.guindaPrimary),
border: const OutlineInputBorder(), filled: true, fillColor: Colors.white));
Widget _timeButton(String label, String value, VoidCallback onTap) =>
@@ -257,7 +257,7 @@ class _CreateRouteScreenState extends State<CreateRouteScreen> {
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade400)),
child: Row(children: [
const Icon(Icons.access_time, color: AppColors.verdeAdmin, size: 18),
const Icon(Icons.access_time, color: AppColors.guindaPrimary, size: 18),
const SizedBox(width: 8),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),

View File

@@ -193,19 +193,19 @@ class _ExportPdfScreenState extends State<ExportPdfScreen> {
Widget build(BuildContext context) => Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
title: const Text('Exportar Reporte PDF'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado))),
body: Center(child: Padding(padding: const EdgeInsets.all(32), child: Column(
mainAxisAlignment: MainAxisAlignment.center, children: [
Container(width: 100, height: 100,
decoration: BoxDecoration(color: AppColors.verdeAdmin.withOpacity(0.1),
decoration: BoxDecoration(color: AppColors.guindaPrimary.withOpacity(0.1),
shape: BoxShape.circle),
child: const Icon(Icons.picture_as_pdf, size: 52, color: AppColors.verdeAdmin)),
child: const Icon(Icons.picture_as_pdf, size: 52, color: AppColors.guindaPrimary)),
const SizedBox(height: 24),
const Text('Reporte Mensual', style: TextStyle(fontSize: 22,
fontWeight: FontWeight.bold, color: AppColors.verdeAdmin)),
fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)),
const SizedBox(height: 8),
const Text('Genera un PDF con el resumen completo:\nreportes, incidentes y calificaciones.',
textAlign: TextAlign.center,
@@ -215,7 +215,7 @@ class _ExportPdfScreenState extends State<ExportPdfScreen> {
child: ElevatedButton.icon(
onPressed: _generating ? null : _generatePdf,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))),
icon: _generating
? const SizedBox(width: 20, height: 20,
@@ -237,7 +237,7 @@ class _ExportPdfScreenState extends State<ExportPdfScreen> {
fontSize: 13))),
TextButton(onPressed: _generatePdf,
child: const Text('Compartir de nuevo',
style: TextStyle(fontSize: 11, color: AppColors.verdeAdmin))),
style: TextStyle(fontSize: 11, color: AppColors.guindaPrimary))),
])),
],
]))));

View File

@@ -52,34 +52,63 @@ class _ManageConductorsScreenState extends State<ManageConductorsScreen> {
if (existing != null)
SwitchListTile(value: activo, dense: true,
title: Text(activo ? 'Conductor Activo' : 'Conductor Inactivo',
style: TextStyle(color: activo ? AppColors.verdeAdmin : AppColors.rojoError,
style: TextStyle(color: activo ? AppColors.guindaPrimary : AppColors.rojoError,
fontWeight: FontWeight.bold)),
activeColor: AppColors.verdeAdmin,
activeColor: AppColors.guindaPrimary,
onChanged: (v) => setSt(() => activo = v)),
])),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancelar')),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppColors.verdeAdmin,
style: ElevatedButton.styleFrom(backgroundColor: AppColors.guindaPrimary,
foregroundColor: Colors.white),
onPressed: () async {
if (nombreCtrl.text.trim().isEmpty || emailCtrl.text.trim().isEmpty) return;
if (existing == null) {
if (passCtrl.text.length < 6) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('La contrasena debe tener al menos 6 caracteres'),
backgroundColor: AppColors.rojoError));
return;
}
await DbHelper.insertConductor(nombreCtrl.text.trim(),
emailCtrl.text.trim().toLowerCase(), passCtrl.text);
} else {
await DbHelper.updateConductor(existing['id'], nombreCtrl.text.trim(),
emailCtrl.text.trim().toLowerCase());
await DbHelper.updateConductorMeta(existing['id'], activo, notasCtrl.text.trim());
if (nombreCtrl.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Ingresa el nombre del conductor'),
backgroundColor: AppColors.rojoError));
return;
}
if (emailCtrl.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Ingresa el correo electronico'),
backgroundColor: AppColors.rojoError));
return;
}
try {
if (existing == null) {
if (passCtrl.text.length < 6) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('La contrasena debe tener al menos 6 caracteres'),
backgroundColor: AppColors.rojoError));
return;
}
await DbHelper.insertConductor(
nombreCtrl.text.trim(),
emailCtrl.text.trim().toLowerCase(),
passCtrl.text);
if (ctx.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Conductor creado correctamente'),
backgroundColor: AppColors.verdeExito));
}
} else {
await DbHelper.updateConductor(existing['id'], nombreCtrl.text.trim(),
emailCtrl.text.trim().toLowerCase());
await DbHelper.updateConductorMeta(
existing['id'], activo, notasCtrl.text.trim());
}
if (ctx.mounted) Navigator.pop(ctx);
await _load();
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(e.toString().contains('UNIQUE')
? 'Ese correo ya está registrado'
: 'Error: ${e.toString()}'),
backgroundColor: AppColors.rojoError));
}
}
if (ctx.mounted) Navigator.pop(ctx);
await _load();
},
child: Text(existing == null ? 'Crear' : 'Guardar')),
])));
@@ -89,7 +118,7 @@ class _ManageConductorsScreenState extends State<ManageConductorsScreen> {
Widget build(BuildContext context) => Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
title: Text('Conductores (${_conductores.length})'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
@@ -110,7 +139,7 @@ class _ManageConductorsScreenState extends State<ManageConductorsScreen> {
style: TextStyle(color: AppColors.grisTexto)),
const SizedBox(height: 16),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(backgroundColor: AppColors.verdeAdmin,
style: ElevatedButton.styleFrom(backgroundColor: AppColors.guindaPrimary,
foregroundColor: Colors.white),
onPressed: () => _showFormDialog(),
icon: const Icon(Icons.add), label: const Text('Agregar primer conductor')),
@@ -126,17 +155,17 @@ class _ManageConductorsScreenState extends State<ManageConductorsScreen> {
margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10),
side: BorderSide(color: activo
? AppColors.verdeAdmin.withOpacity(0.3)
? AppColors.guindaPrimary.withOpacity(0.3)
: AppColors.rojoError.withOpacity(0.3))),
child: Padding(padding: const EdgeInsets.all(14), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
CircleAvatar(radius: 22,
backgroundColor: activo
? AppColors.verdeAdmin.withOpacity(0.15)
? AppColors.guindaPrimary.withOpacity(0.15)
: Colors.grey.shade200,
child: Icon(Icons.person,
color: activo ? AppColors.verdeAdmin : AppColors.grisTexto, size: 24)),
color: activo ? AppColors.guindaPrimary : AppColors.grisTexto, size: 24)),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(c['nombre'] ?? '', style: const TextStyle(
@@ -146,12 +175,12 @@ class _ManageConductorsScreenState extends State<ManageConductorsScreen> {
])),
Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: activo ? AppColors.verdeAdmin.withOpacity(0.1)
color: activo ? AppColors.guindaPrimary.withOpacity(0.1)
: AppColors.rojoError.withOpacity(0.1),
borderRadius: BorderRadius.circular(10)),
child: Text(activo ? 'Activo' : 'Inactivo',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold,
color: activo ? AppColors.verdeAdmin : AppColors.rojoError))),
color: activo ? AppColors.guindaPrimary : AppColors.rojoError))),
IconButton(icon: const Icon(Icons.edit_outlined, size: 18),
onPressed: () => _showFormDialog(existing: c)),
]),

View File

@@ -40,20 +40,27 @@ class _ReviewScreenState extends State<ReviewScreen> {
}
setState(() => _loading = true);
await DbHelper.insertReview(ReviewModel(
userId: auth.currentUser!.id!,
colonia: widget.colonia,
routeId: widget.routeId,
estrellas: _estrellas,
comentario: _comentCtrl.text.trim().isEmpty
? 'Sin comentario' : _comentCtrl.text.trim(),
fecha: DateTime.now().toIso8601String(),
nombreUsuario: auth.currentUser!.nombre,
));
context.read<RouteSimulatorService>().clearReviewPrompt(widget.routeId);
if (!mounted) return;
setState(() { _loading = false; _sent = true; });
try {
await DbHelper.insertReview(ReviewModel(
userId: auth.currentUser!.id!,
colonia: widget.colonia,
routeId: widget.routeId,
estrellas: _estrellas,
comentario: _comentCtrl.text.trim().isEmpty
? 'Sin comentario' : _comentCtrl.text.trim(),
fecha: DateTime.now().toIso8601String(),
nombreUsuario: auth.currentUser!.nombre,
));
context.read<RouteSimulatorService>().clearReviewPrompt(widget.routeId);
if (!mounted) return;
setState(() { _loading = false; _sent = true; });
} catch (e) {
if (!mounted) return;
setState(() => _loading = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Error al guardar: ${e.toString()}'),
backgroundColor: AppColors.rojoError));
}
}
@override

View File

@@ -71,14 +71,14 @@ class _DriverHomeScreenState extends State<DriverHomeScreen> {
selectedIndex: _tab,
onDestinationSelected: (i) => setState(()=>_tab=i),
backgroundColor: Colors.white,
indicatorColor: AppColors.moradoConductor.withOpacity(0.15),
indicatorColor: AppColors.guindaPrimary.withOpacity(0.15),
destinations: const [
NavigationDestination(icon:Icon(Icons.dashboard_outlined),
selectedIcon:Icon(Icons.dashboard,color:AppColors.moradoConductor),label:'Mi Ruta'),
selectedIcon:Icon(Icons.dashboard,color:AppColors.guindaPrimary),label:'Mi Ruta'),
NavigationDestination(icon:Icon(Icons.map_outlined),
selectedIcon:Icon(Icons.map,color:AppColors.moradoConductor),label:'Mapa'),
selectedIcon:Icon(Icons.map,color:AppColors.guindaPrimary),label:'Mapa'),
NavigationDestination(icon:Icon(Icons.report_problem_outlined),
selectedIcon:Icon(Icons.report_problem,color:AppColors.moradoConductor),label:'Incidente'),
selectedIcon:Icon(Icons.report_problem,color:AppColors.guindaPrimary),label:'Incidente'),
],
),
);
@@ -114,7 +114,7 @@ class _DriverMainTabState extends State<_DriverMainTab> {
? widget.sim.isGpsActive(widget.todayRouteId!) : true;
return CustomScrollView(slivers:[
SliverAppBar(pinned:true, backgroundColor:AppColors.moradoConductor, foregroundColor:Colors.white,
SliverAppBar(pinned:true, backgroundColor:AppColors.guindaPrimary, foregroundColor:Colors.white,
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
child:Container(height:4,color:AppColors.dorado)),
title:Text('Conductor: ${widget.auth.currentUser?.nombre.split(' ').first ?? ''}',
@@ -125,16 +125,16 @@ class _DriverMainTabState extends State<_DriverMainTab> {
SliverPadding(padding:const EdgeInsets.all(14),sliver:SliverList(delegate:SliverChildListDelegate([
// Ruta de hoy
Card(color:AppColors.moradoConductor.withOpacity(0.08),
Card(color:AppColors.guindaPrimary.withOpacity(0.08),
shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(12),
side:BorderSide(color:AppColors.moradoConductor.withOpacity(0.3))),
side:BorderSide(color:AppColors.guindaPrimary.withOpacity(0.3))),
child:Padding(padding:const EdgeInsets.all(14),child:Column(
crossAxisAlignment:CrossAxisAlignment.start, children:[
Row(children:[
const Icon(Icons.today,color:AppColors.moradoConductor),
const Icon(Icons.today,color:AppColors.guindaPrimary),
const SizedBox(width:8),
Text('Hoy — ${_todayLabel()}',
style:const TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor,fontSize:15)),
style:const TextStyle(fontWeight:FontWeight.bold,color:AppColors.guindaPrimary,fontSize:15)),
]),
const Divider(),
if (widget.route != null)...[
@@ -155,7 +155,7 @@ class _DriverMainTabState extends State<_DriverMainTab> {
const SizedBox(height:8),
LinearProgressIndicator(value:(posIdx+1)/8,
backgroundColor:Colors.grey.shade300,
valueColor:const AlwaysStoppedAnimation<Color>(AppColors.moradoConductor)),
valueColor:const AlwaysStoppedAnimation<Color>(AppColors.guindaPrimary)),
const SizedBox(height:6),
Text(widget.sim.getEtaText(widget.todayRouteId??''),
style:const TextStyle(fontSize:13,fontWeight:FontWeight.w500)),
@@ -168,7 +168,7 @@ class _DriverMainTabState extends State<_DriverMainTab> {
Card(child:Padding(padding:const EdgeInsets.all(12),child:Column(
crossAxisAlignment:CrossAxisAlignment.start, children:[
const Text('📋 Instrucciones de Ruta',
style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor)),
style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.guindaPrimary)),
const Divider(),
const Text('• Sigue la ruta asignada sin desviaciones\n'
'• Mantén el GPS activo en todo momento\n'
@@ -208,7 +208,7 @@ class _DriverMainTabState extends State<_DriverMainTab> {
Card(child:Padding(padding:const EdgeInsets.all(12),child:Column(
crossAxisAlignment:CrossAxisAlignment.start, children:[
const Text('Mi Horario',
style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor)),
style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.guindaPrimary)),
const Divider(),
if (widget.assignments.isEmpty)
const Text('Sin asignaciones. Contacta al administrador.',
@@ -232,16 +232,16 @@ class _DriverMainTabState extends State<_DriverMainTab> {
try { found = all.firstWhere((a)=>a.diaSemana==dia); break; } catch(_){}
}
return Container(padding:const EdgeInsets.all(10),
decoration:BoxDecoration(color:AppColors.moradoConductor.withOpacity(0.06),
decoration:BoxDecoration(color:AppColors.guindaPrimary.withOpacity(0.06),
borderRadius:BorderRadius.circular(8),
border:Border.all(color:AppColors.moradoConductor.withOpacity(0.2))),
border:Border.all(color:AppColors.guindaPrimary.withOpacity(0.2))),
child:Row(children:[
const Icon(Icons.calendar_today,size:14,color:AppColors.moradoConductor),
const Icon(Icons.calendar_today,size:14,color:AppColors.guindaPrimary),
const SizedBox(width:6),
Expanded(child:Text(label,style:const TextStyle(fontWeight:FontWeight.w600,fontSize:12))),
if (found!=null)
Container(padding:const EdgeInsets.symmetric(horizontal:8,vertical:3),
decoration:BoxDecoration(color:AppColors.moradoConductor,borderRadius:BorderRadius.circular(8)),
decoration:BoxDecoration(color:AppColors.guindaPrimary,borderRadius:BorderRadius.circular(8)),
child:Text('${found.routeId}${found.turno}',
style:const TextStyle(fontSize:11,color:Colors.white,fontWeight:FontWeight.bold)))
else
@@ -262,7 +262,7 @@ class _DriverMapTab extends StatelessWidget {
@override
Widget build(BuildContext context) => Scaffold(
appBar:AppBar(automaticallyImplyLeading:false,
backgroundColor:AppColors.moradoConductor,foregroundColor:Colors.white,
backgroundColor:AppColors.guindaPrimary,foregroundColor:Colors.white,
title:Text(route.name,style:const TextStyle(fontSize:13)),
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
child:Container(height:4,color:AppColors.dorado))),
@@ -333,7 +333,7 @@ class _DriverReportesTabState extends State<_DriverReportesTab> {
Widget build(BuildContext context) => Scaffold(
backgroundColor:AppColors.grisFondo,
appBar:AppBar(automaticallyImplyLeading:false,
backgroundColor:AppColors.moradoConductor,foregroundColor:Colors.white,
backgroundColor:AppColors.guindaPrimary,foregroundColor:Colors.white,
title:const Text('Reportar Incidente'),
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
child:Container(height:4,color:AppColors.dorado))),
@@ -349,14 +349,14 @@ class _DriverReportesTabState extends State<_DriverReportesTab> {
if (widget.todayRouteId != null)
Container(margin:const EdgeInsets.only(bottom:12),
padding:const EdgeInsets.all(10),
decoration:BoxDecoration(color:AppColors.moradoConductor.withOpacity(0.08),
decoration:BoxDecoration(color:AppColors.guindaPrimary.withOpacity(0.08),
borderRadius:BorderRadius.circular(8),
border:Border.all(color:AppColors.moradoConductor.withOpacity(0.3))),
border:Border.all(color:AppColors.guindaPrimary.withOpacity(0.3))),
child:Row(children:[
const Icon(Icons.route,color:AppColors.moradoConductor,size:16),
const Icon(Icons.route,color:AppColors.guindaPrimary,size:16),
const SizedBox(width:6),
Text('Incidente en: ${widget.todayRouteId}',
style:const TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor,fontSize:13)),
style:const TextStyle(fontWeight:FontWeight.bold,color:AppColors.guindaPrimary,fontSize:13)),
]))
else
Container(margin:const EdgeInsets.only(bottom:12),
@@ -369,12 +369,12 @@ class _DriverReportesTabState extends State<_DriverReportesTab> {
child:Padding(padding:const EdgeInsets.all(16),child:Column(
crossAxisAlignment:CrossAxisAlignment.start, children:[
const Text('Tipo de incidente',
style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor,fontSize:15)),
style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.guindaPrimary,fontSize:15)),
const SizedBox(height:8),
..._tipos.entries.map((e)=>RadioListTile<String>(dense:true,
value:e.key,groupValue:_tipo,
title:Text(e.value,style:const TextStyle(fontSize:13)),
activeColor:AppColors.moradoConductor,
activeColor:AppColors.guindaPrimary,
onChanged:(v)=>setState(()=>_tipo=v!))),
const SizedBox(height:10),
const Text('Descripción',style:TextStyle(fontWeight:FontWeight.w600,fontSize:13)),
@@ -395,7 +395,7 @@ class _DriverReportesTabState extends State<_DriverReportesTab> {
SizedBox(width:double.infinity,height:48,
child:ElevatedButton.icon(
onPressed:(_loading||widget.todayRouteId==null)?null:_enviar,
style:ElevatedButton.styleFrom(backgroundColor:AppColors.moradoConductor,
style:ElevatedButton.styleFrom(backgroundColor:AppColors.guindaPrimary,
foregroundColor:Colors.white,
shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(8))),
icon:_loading?const SizedBox(width:18,height:18,
@@ -408,11 +408,11 @@ class _DriverReportesTabState extends State<_DriverReportesTab> {
const SizedBox(height:16),
const Align(alignment:Alignment.centerLeft,
child:Text('Mis incidentes de hoy',
style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor,fontSize:14))),
style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.guindaPrimary,fontSize:14))),
const SizedBox(height:8),
..._misIncidentes.take(5).map((a)=>Card(margin:const EdgeInsets.only(bottom:6),
child:ListTile(dense:true,
leading:CircleAvatar(backgroundColor:AppColors.moradoConductor,radius:16,
leading:CircleAvatar(backgroundColor:AppColors.guindaPrimary,radius:16,
child:const Icon(Icons.warning,color:Colors.white,size:14)),
title:Text(_tipos[a.tipo]??a.tipo,
style:const TextStyle(fontSize:12,fontWeight:FontWeight.w600)),
@@ -436,7 +436,7 @@ class _NotifBanner extends StatelessWidget {
final color = notif.event==NotifEvent.gpsLost?Colors.red.shade800
:notif.event==NotifEvent.truckStopped?AppColors.naranjaAlerta
:notif.event==NotifEvent.routeCancelled?AppColors.rojoError
:AppColors.moradoConductor;
:AppColors.guindaPrimary;
return Material(color:Colors.transparent,
child:Container(margin:const EdgeInsets.all(10),
decoration:BoxDecoration(color:color,borderRadius:BorderRadius.circular(12),

View File

@@ -20,155 +20,234 @@ class _ReporteChatScreenState extends State<ReporteChatScreen> {
List<Map<String, dynamic>> _msgs = [];
bool _loading = true;
Timer? _timer;
String? _myRol;
int? _myUserId;
@override void initState() { super.initState(); _load();
_timer = Timer.periodic(const Duration(seconds: 5), (_) => _load()); }
@override
void initState() {
super.initState();
final auth = context.read<AuthService>();
_myRol = auth.currentUser?.rol ?? 'CIUDADANO';
_myUserId = auth.currentUser?.id;
_load();
_timer = Timer.periodic(const Duration(seconds: 4), (_) => _load());
}
Future<void> _load() async {
final auth = context.read<AuthService>();
final rol = auth.currentUser?.rol ?? 'CIUDADANO';
// Cargar TODOS los mensajes del reporte sin filtro de rol
final msgs = await DbHelper.getChatMsgs(widget.reporteId);
await DbHelper.markChatRead(widget.reporteId, rol);
// Marcar como leídos los que NO son míos
if (_myRol != null) {
try {
await DbHelper.markChatRead(widget.reporteId, _myRol!);
} catch (_) {}
}
if (mounted) {
setState(() { _msgs = msgs; _loading = false; });
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scroll.hasClients) _scroll.animateTo(_scroll.position.maxScrollExtent,
duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
});
_scrollToBottom();
}
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scroll.hasClients) {
_scroll.animateTo(_scroll.position.maxScrollExtent,
duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
}
});
}
Future<void> _send() async {
final text = _ctrl.text.trim();
if (text.isEmpty) return;
final auth = context.read<AuthService>();
final user = auth.currentUser;
if (user == null) return;
if (text.isEmpty || _myRol == null || _myUserId == null) return;
_ctrl.clear();
await DbHelper.insertChatMsg(widget.reporteId, user.id!, user.rol, text);
await DbHelper.insertChatMsg(widget.reporteId, _myUserId!, _myRol!, text);
await _load();
}
bool _isMe(Map<String, dynamic> msg) {
// Un mensaje es mío si fue enviado con el mismo rol (admin ve sus msgs, ciudadano ve los suyos)
final msgRol = msg['rol'] as String? ?? '';
final msgUserId = msg['user_id'] as int?;
// Comparar por user_id si disponible, sino por rol
if (_myUserId != null && msgUserId != null) return msgUserId == _myUserId;
return msgRol == _myRol;
}
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthService>();
final myRol = auth.currentUser?.rol ?? 'CIUDADANO';
final isAdmin = myRol == 'ADMINISTRADOR';
final accent = isAdmin ? AppColors.verdeAdmin : AppColors.guindaPrimary;
final isAdmin = _myRol == 'ADMINISTRADOR';
return Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(
backgroundColor: accent, foregroundColor: Colors.white,
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
title: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('Chat del Reporte', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
Text('Folio: ${widget.folio}', style: const TextStyle(fontSize: 11, color: Colors.white70)),
const Text('Chat del Reporte',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
Text('Folio: ${widget.folio}',
style: const TextStyle(fontSize: 11, color: Colors.white70)),
]),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
actions: [if (widget.isClosed) Container(margin: const EdgeInsets.only(right: 12),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: AppColors.verdeExito, borderRadius: BorderRadius.circular(12)),
child: const Text('COMPLETADO', style: TextStyle(fontSize: 10,
color: Colors.white, fontWeight: FontWeight.bold)))],
actions: [
if (widget.isClosed)
Container(margin: const EdgeInsets.only(right: 12),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: AppColors.verdeExito,
borderRadius: BorderRadius.circular(12)),
child: const Text('COMPLETADO',
style: TextStyle(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold))),
IconButton(icon: const Icon(Icons.refresh), onPressed: _load),
],
),
body: Column(children: [
if (widget.isClosed) Container(
width: double.infinity, padding: const EdgeInsets.all(10),
color: AppColors.verdeExito.withOpacity(0.08),
child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.lock, size: 14, color: AppColors.verdeExito),
SizedBox(width: 6),
Text('Reporte completado. Chat cerrado.',
style: TextStyle(fontSize: 12, color: AppColors.verdeExito, fontWeight: FontWeight.w600)),
])),
if (widget.isClosed)
Container(width: double.infinity, padding: const EdgeInsets.all(10),
color: AppColors.verdeExito.withOpacity(0.08),
child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.lock, size: 14, color: AppColors.verdeExito),
SizedBox(width: 6),
Text('Reporte completado Chat cerrado',
style: TextStyle(fontSize: 12, color: AppColors.verdeExito,
fontWeight: FontWeight.w600)),
])),
// Mensajes
Expanded(child: _loading
? const Center(child: CircularProgressIndicator())
? const Center(child: CircularProgressIndicator(
color: AppColors.guindaPrimary))
: _msgs.isEmpty
? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.chat_bubble_outline, size: 48, color: Colors.grey.shade400),
const Icon(Icons.chat_bubble_outline, size: 48, color: AppColors.grisTexto),
const SizedBox(height: 12),
Text(isAdmin ? 'Inicia la conversacion con el ciudadano'
: 'Escribe tu mensaje al Ayuntamiento',
style: TextStyle(color: Colors.grey.shade500)),
Text(isAdmin
? 'Sin mensajes aún. Inicia la conversación.'
: 'Escribe tu mensaje al Ayuntamiento de Celaya.',
style: const TextStyle(color: AppColors.grisTexto),
textAlign: TextAlign.center),
]))
: ListView.builder(
controller: _scroll, padding: const EdgeInsets.all(12),
controller: _scroll,
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
itemCount: _msgs.length,
itemBuilder: (_, i) {
final m = _msgs[i];
final rol = m['rol'] as String;
final isMe = rol == myRol;
final m = _msgs[i];
final me = _isMe(m);
final rol = m['rol'] as String? ?? '';
final fecha = DateTime.tryParse(m['fecha'] as String? ?? '');
final hora = fecha != null
? '${fecha.hour.toString().padLeft(2,'0')}:${fecha.minute.toString().padLeft(2,'0')}' : '';
? '${fecha.hour.toString().padLeft(2,'0')}:${fecha.minute.toString().padLeft(2,'0')}'
: '';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start,
mainAxisAlignment: me ? MainAxisAlignment.end : MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (!isMe) ...[
CircleAvatar(radius: 14,
backgroundColor: (rol=='ADMINISTRADOR' ? AppColors.verdeAdmin : AppColors.guindaPrimary).withOpacity(0.15),
child: Icon(rol=='ADMINISTRADOR' ? Icons.admin_panel_settings : Icons.person,
size: 14, color: rol=='ADMINISTRADOR' ? AppColors.verdeAdmin : AppColors.guindaPrimary)),
if (!me) ...[
CircleAvatar(radius: 16,
backgroundColor: AppColors.guindaPrimary.withOpacity(0.15),
child: Icon(
rol == 'ADMINISTRADOR' ? Icons.admin_panel_settings : Icons.person,
size: 15, color: AppColors.guindaPrimary)),
const SizedBox(width: 6),
],
Flexible(child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.72),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: isMe ? accent : Colors.white,
color: me ? AppColors.guindaPrimary : Colors.white,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16), topRight: const Radius.circular(16),
bottomLeft: Radius.circular(isMe ? 16 : 4),
bottomRight: Radius.circular(isMe ? 4 : 16),
topLeft: const Radius.circular(18),
topRight: const Radius.circular(18),
bottomLeft: Radius.circular(me ? 18 : 4),
bottomRight: Radius.circular(me ? 4 : 18),
),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.07), blurRadius: 4)],
boxShadow: [BoxShadow(
color: Colors.black.withOpacity(0.07),
blurRadius: 4, offset: const Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (!me) ...[
Text(
rol == 'ADMINISTRADOR' ? 'Ayuntamiento de Celaya' : 'Ciudadano',
style: const TextStyle(fontSize: 10,
fontWeight: FontWeight.bold,
color: AppColors.guindaPrimary)),
const SizedBox(height: 2),
],
Text(m['mensaje'] as String? ?? '',
style: TextStyle(fontSize: 13, height: 1.4,
color: me ? Colors.white : AppColors.negroTexto)),
const SizedBox(height: 2),
Align(alignment: Alignment.bottomRight,
child: Text(hora, style: TextStyle(fontSize: 9,
color: me ? Colors.white54 : AppColors.grisTexto))),
],
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
if (!isMe) Text(rol=='ADMINISTRADOR' ? 'Ayuntamiento' : 'Ciudadano',
style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold,
color: rol=='ADMINISTRADOR' ? AppColors.verdeAdmin : AppColors.guindaPrimary)),
Text(m['mensaje'] as String? ?? '',
style: TextStyle(fontSize: 13, height: 1.4,
color: isMe ? Colors.white : AppColors.negroTexto)),
Text(hora, style: TextStyle(fontSize: 9,
color: isMe ? Colors.white60 : AppColors.grisTexto)),
]),
)),
if (isMe) const SizedBox(width: 6),
if (me) const SizedBox(width: 6),
],
),
);
})),
if (!widget.isClosed) Container(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
decoration: BoxDecoration(color: Colors.white,
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 4, offset: const Offset(0,-2))]),
child: SafeArea(top: false, child: Row(children: [
Expanded(child: TextField(
controller: _ctrl, maxLines: 3, minLines: 1,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
hintText: isAdmin ? 'Responde al ciudadano...' : 'Escribe al Ayuntamiento...',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(24), borderSide: BorderSide.none),
filled: true, fillColor: AppColors.grisFondo,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), isDense: true),
)),
const SizedBox(width: 8),
CircleAvatar(radius: 22, backgroundColor: accent,
child: IconButton(icon: const Icon(Icons.send, color: Colors.white, size: 18), onPressed: _send)),
])))
else Container(padding: const EdgeInsets.all(14), color: Colors.white,
// Input
if (!widget.isClosed)
Container(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.08),
blurRadius: 6, offset: const Offset(0, -2))]),
child: SafeArea(top: false, child: Row(children: [
Expanded(child: TextField(
controller: _ctrl,
maxLines: 4, minLines: 1,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
hintText: isAdmin
? 'Responde al ciudadano...'
: 'Escribe al Ayuntamiento de Celaya...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide.none),
filled: true, fillColor: AppColors.grisFondo,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
isDense: true,
),
onSubmitted: (_) => _send(),
)),
const SizedBox(width: 8),
CircleAvatar(
radius: 24,
backgroundColor: AppColors.guindaPrimary,
child: IconButton(
icon: const Icon(Icons.send, color: Colors.white, size: 20),
onPressed: _send)),
])))
else
Container(
padding: const EdgeInsets.all(14), color: Colors.white,
child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.lock_outline, size: 16, color: AppColors.grisTexto),
SizedBox(width: 6),
Text('Chat cerrado', style: TextStyle(color: AppColors.grisTexto, fontSize: 12)),
Text('Chat cerrado — Reporte completado',
style: TextStyle(color: AppColors.grisTexto, fontSize: 12)),
])),
]),
);
}
@override void dispose() { _ctrl.dispose(); _scroll.dispose(); _timer?.cancel(); super.dispose(); }
@override
void dispose() {
_ctrl.dispose(); _scroll.dispose(); _timer?.cancel();
super.dispose();
}
}