Primera app funcional

This commit is contained in:
2026-05-22 18:27:43 -06:00
parent 43661dc2b0
commit 37e83a8226
30 changed files with 4053 additions and 291 deletions

View File

@@ -0,0 +1,786 @@
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 '../../widgets/route_map_widget.dart';
class AdminDashboardScreen extends StatefulWidget {
const AdminDashboardScreen({super.key});
@override State<AdminDashboardScreen> createState() => _AdminDashboardScreenState();
}
class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
int _tab = 0;
@override
Widget build(BuildContext context) {
final sim = context.watch<RouteSimulatorService>();
final auth = context.watch<AuthService>();
final last = sim.lastNotification;
final tabs = [
_AdminHomeTab(sim:sim, auth:auth),
_AdminMapTab(sim:sim),
_AdminReportesTab(),
_AdminAssignmentsTab(),
_AdminAlertasTab(sim:sim),
];
return Scaffold(
body: Stack(children:[
tabs[_tab],
if (last!=null) Positioned(top:MediaQuery.of(context).padding.top+8,left:0,right:0,
child:_AdminBanner(notif:last,onDismiss:sim.dismissNotification)),
]),
bottomNavigationBar: NavigationBar(
selectedIndex:_tab,
onDestinationSelected:(i)=>setState(()=>_tab=i),
backgroundColor:Colors.white,
indicatorColor:AppColors.verdeAdmin.withOpacity(0.15),
destinations:const[
NavigationDestination(icon:Icon(Icons.dashboard_outlined),
selectedIcon:Icon(Icons.dashboard,color:AppColors.verdeAdmin),label:'Panel'),
NavigationDestination(icon:Icon(Icons.map_outlined),
selectedIcon:Icon(Icons.map,color:AppColors.verdeAdmin),label:'Mapa'),
NavigationDestination(icon:Icon(Icons.report_outlined),
selectedIcon:Icon(Icons.report,color:AppColors.verdeAdmin),label:'Reportes'),
NavigationDestination(icon:Icon(Icons.calendar_month_outlined),
selectedIcon:Icon(Icons.calendar_month,color:AppColors.verdeAdmin),label:'Asignar'),
NavigationDestination(icon:Icon(Icons.warning_outlined),
selectedIcon:Icon(Icons.warning,color:AppColors.verdeAdmin),label:'Alertas'),
],
),
);
}
}
// ── TAB 1: Control de rutas ───────────────────────────────────────────────
class _AdminHomeTab extends StatefulWidget {
final RouteSimulatorService sim; final AuthService auth;
const _AdminHomeTab({required this.sim, required this.auth});
@override State<_AdminHomeTab> createState() => _AdminHomeTabState();
}
class _AdminHomeTabState extends State<_AdminHomeTab> {
List<RouteStatusModel> _statuses = [];
List<AlertaModel> _conductorIncidentes = [];
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final s = await DbHelper.getAllRouteStatuses();
final inc = await DbHelper.getIncidentesConductor();
if (mounted) setState(() { _statuses = s; _conductorIncidentes = inc; });
}
String _getStatus(String rid) {
try { return _statuses.firstWhere((s) => s.routeId == rid).status; }
catch (_) { return RouteStatus.enRuta; }
}
String? _getMensaje(String rid) {
try { return _statuses.firstWhere((s) => s.routeId == rid).mensaje; }
catch (_) { return null; }
}
// Incidentes del conductor asociados a esta ruta (por número)
List<AlertaModel> _getIncidentesPorRuta(String routeId) {
return _conductorIncidentes
.where((i) => !i.resuelta)
.where((i) => i.routeId.contains(routeId) ||
// Si es incidente de conductor sin routeId específico, mostrar en todas
i.routeId.startsWith('CONDUCTOR-'))
.take(2)
.toList();
}
Future<void> _changeStatus(String routeId, String status, String? msg) async {
await DbHelper.upsertRouteStatus(RouteStatusModel(
routeId: routeId, status: status, mensaje: msg,
updatedAt: DateTime.now().toIso8601String()));
if (status == RouteStatus.cancelada || status == RouteStatus.fallaMecanica || status == RouteStatus.retrasada) {
final emoji = status == RouteStatus.cancelada ? ''
: status == RouteStatus.fallaMecanica ? '🔧' : '⏱️';
final titulo = status == RouteStatus.cancelada ? 'Ruta Cancelada'
: status == RouteStatus.fallaMecanica ? 'Falla Mecánica' : 'Servicio con Retraso';
final cuerpo = (msg != null && msg.isNotEmpty)
? '$emoji $msg'
: '$emoji La ruta $routeId ${status == RouteStatus.cancelada ? "ha sido cancelada hoy" : status == RouteStatus.fallaMecanica ? "reportó una falla mecánica" : "presenta un retraso"}. Pendiente reprogramación.';
widget.sim.fireCustomNotification(titulo, cuerpo, routeId,
status == RouteStatus.cancelada ? NotifEvent.routeCancelled : NotifEvent.truckStopped);
await DbHelper.insertAlerta(AlertaModel(
tipo: 'RUTA_$status', routeId: routeId, mensaje: cuerpo,
fecha: DateTime.now().toIso8601String()));
}
await _load();
setState(() {});
}
@override
Widget build(BuildContext context) {
return CustomScrollView(slivers: [
SliverAppBar(pinned: true, backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
title: const Text('Panel Administrador', style: TextStyle(fontWeight: FontWeight.bold)),
actions: [
IconButton(icon: const Icon(Icons.refresh), onPressed: _load),
IconButton(icon: const Icon(Icons.logout),
onPressed: () async { await widget.auth.logout();
if (context.mounted) Navigator.pushReplacementNamed(context, '/login'); }),
],
),
SliverPadding(padding: const EdgeInsets.all(12), sliver: SliverList(delegate: SliverChildListDelegate([
Row(children: [
_Stat('Rutas', '${routesData.length}', Icons.local_shipping, AppColors.verdeAdmin),
const SizedBox(width: 10),
_Stat('Incidentes', '${_conductorIncidentes.where((i)=>!i.resuelta).length}',
Icons.warning, AppColors.naranjaAlerta),
]),
const SizedBox(height: 14),
const Text('Control de Rutas', style: TextStyle(fontWeight: FontWeight.bold,
fontSize: 16, color: AppColors.verdeAdmin)),
const SizedBox(height: 8),
...routesData.map((r) {
final status = _getStatus(r.routeId);
final mensaje = _getMensaje(r.routeId);
final gpsOk = widget.sim.isGpsActive(r.routeId);
final nightIcon = r.turno == 'NOCTURNO' ? '🌙 ' : r.turno == 'VESPERTINO' ? '🌅 ' : '🌄 ';
final incidentes = _getIncidentesPorRuta(r.routeId);
return Card(margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10),
side: BorderSide(color: RouteStatus.color(status).withOpacity(0.4), width: 1.2)),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// Cabecera ruta
ListTile(dense: true,
leading: Container(width: 8, height: 44,
decoration: BoxDecoration(color: RouteStatus.color(status),
borderRadius: BorderRadius.circular(4))),
title: Text('${r.routeId}${r.name}',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700)),
subtitle: Wrap(spacing: 6, children: [
Text(RouteStatus.label(status),
style: TextStyle(fontSize: 11, color: RouteStatus.color(status), fontWeight: FontWeight.w600)),
if (!gpsOk)
const Text('📡 Sin GPS', style: TextStyle(fontSize: 10, color: AppColors.rojoError)),
Text(nightIcon + r.turno, style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
]),
trailing: PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, size: 18),
onSelected: (v) async {
if (v == 'GPS') { widget.sim.simulateGpsLost(r.routeId); return; }
if (v == 'RESTORE') { widget.sim.restoreGps(r.routeId); return; }
String? msg;
if (v == RouteStatus.retrasada) {
final res = await _retrasadaDialog(context);
if (res != null) {
final parts = res.split('|');
final nuevoTurno = parts[0];
final extra = parts.length > 1 ? parts[1] : '';
msg = 'Ruta reprogramada al turno $nuevoTurno. $extra'.trim();
}
} else if ([RouteStatus.cancelada, RouteStatus.fallaMecanica].contains(v)) {
msg = await _inputDialog(context, 'Mensaje / solución para ciudadanos');
}
await _changeStatus(r.routeId, v, msg);
},
itemBuilder: (_) => [
const PopupMenuItem(value: 'EN_RUTA', child: Text('✅ En Ruta — Continúa')),
const PopupMenuItem(value: 'RETRASADA', child: Text('⏱️ Marcar Retrasada')),
const PopupMenuItem(value: 'CANCELADA', child: Text('❌ Cancelar y Notificar')),
const PopupMenuItem(value: 'FALLA_MECANICA', child: Text('🔧 Falla Mecánica')),
const PopupMenuDivider(),
const PopupMenuItem(value: 'GPS', child: Text('📡 Simular GPS Perdido')),
const PopupMenuItem(value: 'RESTORE', child: Text('📶 Restaurar GPS')),
],
),
),
// Mensaje del admin si hay
if (mensaje != null && mensaje.isNotEmpty && status != RouteStatus.enRuta)
Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: RouteStatus.color(status).withOpacity(0.08),
borderRadius: BorderRadius.circular(6),
),
child: Row(children: [
Icon(Icons.message_outlined, size: 13, color: RouteStatus.color(status)),
const SizedBox(width: 6),
Expanded(child: Text('Msg ciudadanos: $mensaje',
style: TextStyle(fontSize: 11, color: RouteStatus.color(status)))),
]),
),
),
// Incidentes de conductor pendientes para esta ruta
if (incidentes.isNotEmpty) ...[
const Divider(height: 1, indent: 14, endIndent: 14),
Padding(
padding: const EdgeInsets.fromLTRB(14, 6, 14, 2),
child: Row(children: [
const Icon(Icons.build, size: 13, color: AppColors.moradoConductor),
const SizedBox(width: 4),
const Text('Incidentes del conductor:',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 11,
color: AppColors.moradoConductor)),
]),
),
...incidentes.map((inc) => Padding(
padding: const EdgeInsets.fromLTRB(14, 2, 14, 2),
child: Row(children: [
Container(width: 6, height: 6,
decoration: const BoxDecoration(color: AppColors.moradoConductor,
shape: BoxShape.circle)),
const SizedBox(width: 6),
Expanded(child: Text(inc.mensaje,
style: const TextStyle(fontSize: 11), maxLines: 1,
overflow: TextOverflow.ellipsis)),
TextButton(
onPressed: () async {
// Mostrar diálogo: ¿qué hacer con este incidente?
final accion = await _incidenteDialog(context, inc.mensaje);
if (accion != null) {
await DbHelper.resolverAlerta(inc.id!);
// Soporta formato RETRASADA:TURNO para reprogramación
String realStatus = accion;
String msg = 'Incidente: ${inc.mensaje.substring(0, inc.mensaje.length.clamp(0, 40))}';
if (accion.startsWith('RETRASADA:')) {
final parts = accion.split(':');
realStatus = 'RETRASADA';
final turno = parts.length > 1 ? parts[1] : 'VESPERTINO';
msg = 'Tu ruta ha sido reprogramada al turno $turno por incidente del conductor. '
'Recibirás notificación cuando el camión esté listo.';
}
await _changeStatus(r.routeId, realStatus, msg);
}
},
style: TextButton.styleFrom(
foregroundColor: AppColors.verdeAdmin,
padding: const EdgeInsets.symmetric(horizontal: 8)),
child: const Text('Actuar', style: TextStyle(fontSize: 10)),
),
]),
)),
const SizedBox(height: 6),
],
]));
}),
const SizedBox(height: 80),
]))),
]);
}
Future<String?> _inputDialog(BuildContext ctx, String hint) async {
final ctrl = TextEditingController();
return showDialog<String>(context: ctx, builder: (_) => AlertDialog(
title: const Text('Mensaje para ciudadanos'),
content: TextField(controller: ctrl, maxLines: 2,
decoration: InputDecoration(hintText: hint, border: const OutlineInputBorder())),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancelar')),
ElevatedButton(onPressed: () => Navigator.pop(ctx, ctrl.text), child: const Text('Enviar')),
]));
}
Future<String?> _retrasadaDialog(BuildContext ctx) async {
String turno = 'VESPERTINO';
final ctrl = TextEditingController();
return showDialog<String>(context: ctx, builder: (dCtx) => StatefulBuilder(
builder: (dCtx, setSt) => AlertDialog(
title: const Text('Reprogramar Ruta'),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('¿A qué turno pasará el camión?',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
const SizedBox(height: 8),
Row(children: [
Expanded(child: RadioListTile<String>(dense: true, value: 'MATUTINO',
groupValue: turno, title: const Text('🌄 Matutino'),
onChanged: (v) => setSt(() => turno = v!))),
Expanded(child: RadioListTile<String>(dense: true, value: 'VESPERTINO',
groupValue: turno, title: const Text('🌅 Vespertino'),
onChanged: (v) => setSt(() => turno = v!))),
]),
const SizedBox(height: 8),
TextField(controller: ctrl, maxLines: 2,
decoration: const InputDecoration(
hintText: 'Mensaje adicional para ciudadanos (opcional)',
border: OutlineInputBorder(), isDense: true)),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(dCtx), child: const Text('Cancelar')),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppColors.naranjaAlerta,
foregroundColor: Colors.white),
onPressed: () => Navigator.pop(dCtx, '$turno|${ctrl.text.trim()}'),
child: const Text('Confirmar')),
])));
}
Future<String?> _incidenteDialog(BuildContext ctx, String incMensaje) async {
String turnoSeleccionado = 'VESPERTINO';
return showDialog<String>(context: ctx, builder: (dialogCtx) => StatefulBuilder(
builder: (dialogCtx, setDialogState) => AlertDialog(
title: const Text('Acción sobre el incidente'),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(incMensaje, style: const TextStyle(fontSize: 12, color: AppColors.grisTexto)),
const Divider(),
const Text('Si decides reprogramar, ¿a qué turno?',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)),
const SizedBox(height: 6),
Row(children: [
Expanded(child: RadioListTile<String>(dense: true, value: 'MATUTINO',
groupValue: turnoSeleccionado, title: const Text('🌄 Matutino', style: TextStyle(fontSize: 12)),
onChanged: (v) => setDialogState(() => turnoSeleccionado = v!))),
Expanded(child: RadioListTile<String>(dense: true, value: 'VESPERTINO',
groupValue: turnoSeleccionado, title: const Text('🌅 Vespertino', style: TextStyle(fontSize: 12)),
onChanged: (v) => setDialogState(() => turnoSeleccionado = v!))),
]),
const SizedBox(height: 4),
const Text('¿Qué decisión tomas?', style: TextStyle(fontWeight: FontWeight.bold)),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(dialogCtx), child: const Text('Cerrar')),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(backgroundColor: AppColors.verdeExito, foregroundColor: Colors.white),
onPressed: () => Navigator.pop(dialogCtx, 'EN_RUTA'),
icon: const Icon(Icons.check, size: 14),
label: const Text('Continúa', style: TextStyle(fontSize: 12))),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(backgroundColor: AppColors.naranjaAlerta, foregroundColor: Colors.white),
onPressed: () => Navigator.pop(dialogCtx, 'RETRASADA:$turnoSeleccionado'),
icon: const Icon(Icons.access_time, size: 14),
label: Text('Retraso→$turnoSeleccionado', style: const TextStyle(fontSize: 11))),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(backgroundColor: AppColors.rojoError, foregroundColor: Colors.white),
onPressed: () => Navigator.pop(dialogCtx, 'CANCELADA'),
icon: const Icon(Icons.cancel, size: 14),
label: const Text('Cancelar', style: TextStyle(fontSize: 12))),
])));
}
}
class _AdminMapTab extends StatelessWidget {
final RouteSimulatorService sim;
const _AdminMapTab({required this.sim});
@override
Widget build(BuildContext context) => Scaffold(
appBar:AppBar(automaticallyImplyLeading:false,
backgroundColor:AppColors.verdeAdmin,foregroundColor:Colors.white,
title:const Text('Mapa — Todas las Rutas'),
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
child:Container(height:4,color:AppColors.dorado))),
body:AdminMapWidget(routes:routesData,simulator:sim));
}
// ── TAB 3: Reportes ciudadanos ────────────────────────────────────────────
class _AdminReportesTab extends StatefulWidget {
@override State<_AdminReportesTab> createState() => _AdminReportesTabState();
}
class _AdminReportesTabState extends State<_AdminReportesTab> {
List<Map<String,dynamic>> _reportes = [];
bool _loading = true;
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final r = await DbHelper.getReportesConUsuario();
if (mounted) setState(() { _reportes=r; _loading=false; });
}
static const _tipos = {
'CAMION_NO_PASO':'🚛 No pasó','RETRASO':'⏱️ Retraso',
'RESIDUOS_NO_RECOGIDOS':'🗑️ No recogidos','OTRO':'📝 Otro',
};
@override
Widget build(BuildContext context) => Scaffold(
appBar:AppBar(automaticallyImplyLeading:false,
backgroundColor:AppColors.verdeAdmin,foregroundColor:Colors.white,
title:Text('Reportes Ciudadanos (${_reportes.length})'),
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
child:Container(height:4,color:AppColors.dorado)),
actions:[IconButton(icon:const Icon(Icons.refresh),onPressed:_load)]),
body:_loading?const Center(child:CircularProgressIndicator())
:_reportes.isEmpty?const Center(child:Text('Sin reportes'))
:ListView.builder(padding:const EdgeInsets.all(12),
itemCount:_reportes.length,
itemBuilder:(_,i){
final r = _reportes[i];
final tipo = r['tipo']??'';
final calif = r['calificacion']??5;
final nombre = r['user_nombre']??'Usuario desconocido';
final email = r['user_email']??'';
final colonia = r['colonia']??'';
final routeId = r['route_id']??'';
final estado = r['estado']??'PENDIENTE';
final id = r['id'] as int?;
return Card(margin:const EdgeInsets.only(bottom:8),
child:Padding(padding:const EdgeInsets.all(12),child:Column(
crossAxisAlignment:CrossAxisAlignment.start, children:[
// Quién reportó
Row(children:[
const Icon(Icons.person,color:AppColors.verdeAdmin,size:14),
const SizedBox(width:4),
Expanded(child:Text('$nombre ($email)',
style:const TextStyle(fontWeight:FontWeight.bold,fontSize:12,color:AppColors.verdeAdmin))),
Container(padding:const EdgeInsets.symmetric(horizontal:6,vertical:2),
decoration:BoxDecoration(color:_estadoColor(estado).withOpacity(0.15),
borderRadius:BorderRadius.circular(10)),
child:Text(estado,style:TextStyle(fontSize:9,color:_estadoColor(estado),
fontWeight:FontWeight.bold))),
]),
const SizedBox(height:4),
Row(children:[
const Icon(Icons.location_city,color:AppColors.grisTexto,size:12),
const SizedBox(width:4),
Text('$colonia$routeId',style:const TextStyle(color:AppColors.grisTexto,fontSize:11)),
]),
const SizedBox(height:6),
Text(_tipos[tipo]??tipo,style:const TextStyle(fontWeight:FontWeight.w600,fontSize:13)),
Text(r['descripcion']??'',style:const TextStyle(fontSize:12,color:AppColors.grisTexto)),
const SizedBox(height:6),
Row(children:[
Text(''*calif,style:const TextStyle(fontSize:11)),
const Spacer(),
PopupMenuButton<String>(
child:Text(estado,style:TextStyle(fontSize:11,color:_estadoColor(estado),
fontWeight:FontWeight.bold,decoration:TextDecoration.underline)),
onSelected:(v)async{
if(id!=null) await DbHelper.updateReporteEstado(id,v);
await _load();
},
itemBuilder:(_)=>['PENDIENTE','EN_REVISION','RESUELTO','DESESTIMADO']
.map((e)=>PopupMenuItem(value:e,child:Text(e))).toList()),
]),
])));
}),
);
Color _estadoColor(String e){
switch(e){case'RESUELTO':return AppColors.verdeExito;
case'EN_REVISION':return AppColors.azulInfo;
case'DESESTIMADO':return AppColors.grisTexto;
default:return AppColors.naranjaAlerta;}
}
}
// ── TAB 4: Asignaciones ───────────────────────────────────────────────────
// ── TAB 4: Asignaciones LMV / MJS ────────────────────────────────────────
class _AdminAssignmentsTab extends StatefulWidget {
@override State<_AdminAssignmentsTab> createState() => _AdminAssignmentsTabState();
}
class _AdminAssignmentsTabState extends State<_AdminAssignmentsTab> {
List<UserModel> _conductores = [];
UserModel? _sel;
List<AssignmentModel> _asigs = [];
// Grupos fijos de días
static const _grupoA = ['LUNES','MIERCOLES','VIERNES'];
static const _grupoB = ['MARTES','JUEVES','SABADO'];
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final c = await DbHelper.getUsersByRol('CONDUCTOR');
if (mounted) setState(() => _conductores = c);
}
Future<void> _loadAsigs(int id) async {
final a = await DbHelper.getAsignacionesByConductor(id);
if (mounted) setState(() => _asigs = a);
}
// Obtener asignación de un grupo (busca cualquier día del grupo)
AssignmentModel? _getGrupo(List<String> dias) {
for (final dia in dias) {
try { return _asigs.firstWhere((a) => a.diaSemana == dia); } catch (_) {}
}
return null;
}
// Guardar asignación para todos los días del grupo
Future<void> _saveGrupo(List<String> dias, String routeId, String turno) async {
for (final dia in dias) {
await DbHelper.upsertAsignacion(AssignmentModel(
conductorId: _sel!.id!, routeId: routeId,
diaSemana: dia, turno: turno));
}
await _loadAsigs(_sel!.id!);
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(automaticallyImplyLeading: false,
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
title: const Text('Asignar Rutas a Conductores'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado))),
body: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(children: [
// Info de esquema
Container(padding: const EdgeInsets.all(10), margin: const EdgeInsets.only(bottom:12),
decoration: BoxDecoration(color: Colors.blue.shade50, borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.shade200)),
child: const Text(
'📅 Cada conductor opera en uno de dos bloques:\n'
' Grupo A — Lunes, Miércoles y Viernes\n'
' Grupo B — Martes, Jueves y Sábado',
style: TextStyle(fontSize: 12, color: AppColors.azulInfo))),
DropdownButtonFormField<UserModel>(
decoration: const InputDecoration(labelText: 'Selecciona conductor',
border: OutlineInputBorder(), filled: true, fillColor: Colors.white),
hint: const Text('Conductor...'),
value: _sel,
items: _conductores.map((c) => DropdownMenuItem(value: c,
child: Text(c.nombre, style: const TextStyle(fontSize: 13)))).toList(),
onChanged: (c) { setState(() => _sel = c); if (c != null) _loadAsigs(c.id!); }),
if (_sel != null) ...[
const SizedBox(height: 20),
// GRUPO A
_GrupoRow(
label: 'Grupo A — Lunes, Miércoles y Viernes',
icon: Icons.wb_sunny_outlined,
color: Colors.blue,
current: _getGrupo(_grupoA),
routeIds: routesData.map((r) => r.routeId).toList(),
onSave: (rid, turno) => _saveGrupo(_grupoA, rid, turno),
),
const SizedBox(height: 12),
// GRUPO B
_GrupoRow(
label: 'Grupo B — Martes, Jueves y Sábado',
icon: Icons.wb_twilight,
color: Colors.deepPurple,
current: _getGrupo(_grupoB),
routeIds: routesData.map((r) => r.routeId).toList(),
onSave: (rid, turno) => _saveGrupo(_grupoB, rid, turno),
),
// Resumen actual
if (_asigs.isNotEmpty) ...[
const SizedBox(height: 20),
const Text('Resumen actual', style: TextStyle(fontWeight: FontWeight.bold,
color: AppColors.verdeAdmin, fontSize: 14)),
const SizedBox(height: 8),
Card(child: Padding(padding: const EdgeInsets.all(12), child: Column(children: [
..._asigs.map((a) => Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(children: [
SizedBox(width: 100, child: Text(AppDias.label(a.diaSemana),
style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 12))),
Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(color: AppColors.verdeAdmin.withOpacity(0.1),
borderRadius: BorderRadius.circular(10)),
child: Text('${a.routeId}${a.turno}',
style: const TextStyle(fontSize: 11, color: AppColors.verdeAdmin))),
]))),
]))),
],
],
])),
);
}
// Fila de asignación por grupo (LMV o MJS)
class _GrupoRow extends StatefulWidget {
final String label;
final IconData icon;
final Color color;
final AssignmentModel? current;
final List<String> routeIds;
final Function(String, String) onSave;
const _GrupoRow({required this.label, required this.icon, required this.color,
required this.current, required this.routeIds, required this.onSave});
@override State<_GrupoRow> createState() => _GrupoRowState();
}
class _GrupoRowState extends State<_GrupoRow> {
String? _route;
String _turno = 'MATUTINO';
@override void initState() {
super.initState();
_route = widget.current?.routeId;
_turno = widget.current?.turno ?? 'MATUTINO';
}
@override
Widget build(BuildContext context) => Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10),
side: BorderSide(color: widget.color.withOpacity(0.3))),
child: Padding(padding: const EdgeInsets.all(14), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(widget.icon, color: widget.color, size: 18),
const SizedBox(width: 8),
Expanded(child: Text(widget.label,
style: TextStyle(fontWeight: FontWeight.bold, color: widget.color, fontSize: 13))),
]),
const SizedBox(height: 12),
Row(children: [
Expanded(child: DropdownButtonFormField<String>(
value: _route,
decoration: const InputDecoration(labelText: 'Ruta',
border: OutlineInputBorder(), isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 10)),
hint: const Text('Sin ruta', style: TextStyle(fontSize: 12)),
items: widget.routeIds.map((r) => DropdownMenuItem(value: r,
child: Text(r, style: const TextStyle(fontSize: 12)))).toList(),
onChanged: (v) => setState(() => _route = v))),
const SizedBox(width: 8),
SizedBox(width: 140, child: DropdownButtonFormField<String>(
value: _turno,
decoration: const InputDecoration(labelText: 'Turno',
border: OutlineInputBorder(), isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 10)),
items: const [
DropdownMenuItem(value:'MATUTINO', child:Text('🌄 Matutino',style:TextStyle(fontSize:12))),
DropdownMenuItem(value:'VESPERTINO',child:Text('🌅 Vespertino',style:TextStyle(fontSize:12))),
DropdownMenuItem(value:'NOCTURNO', child:Text('🌙 Nocturno',style:TextStyle(fontSize:12))),
],
onChanged: (v) => setState(() => _turno = v!))),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _route == null ? null : () => widget.onSave(_route!, _turno),
style: ElevatedButton.styleFrom(
backgroundColor: widget.color, foregroundColor: Colors.white,
minimumSize: const Size(50, 42), padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
child: const Icon(Icons.save, size: 18)),
]),
])));
}
class _AdminAlertasTab extends StatefulWidget {
final RouteSimulatorService sim;
const _AdminAlertasTab({required this.sim});
@override State<_AdminAlertasTab> createState() => _AdminAlertasTabState();
}
class _AdminAlertasTabState extends State<_AdminAlertasTab> {
List<AlertaModel> _alertas = [];
bool _soloActivas = false;
@override void initState(){ super.initState(); _load(); }
Future<void> _load() async {
final a = await DbHelper.getAlertas(soloNoResueltas:_soloActivas);
if (mounted) setState(()=>_alertas=a);
}
IconData _icon(String tipo){
if(tipo.startsWith('INCIDENTE_')) return Icons.build;
switch(tipo){
case'GPS_PERDIDO': return Icons.gps_off;
case'CAMION_DETENIDO': return Icons.warning_amber;
default: return Icons.info;
}
}
Color _color(String tipo){
if(tipo.startsWith('INCIDENTE_')) return AppColors.moradoConductor;
switch(tipo){
case'GPS_PERDIDO': return AppColors.rojoError;
case'CAMION_DETENIDO': return AppColors.naranjaAlerta;
case'RUTA_CANCELADA': return AppColors.rojoError;
default: return AppColors.azulInfo;
}
}
@override
Widget build(BuildContext context) => Scaffold(
appBar:AppBar(automaticallyImplyLeading:false,
backgroundColor:AppColors.verdeAdmin,foregroundColor:Colors.white,
title:Text('Alertas (${_alertas.where((a)=>!a.resuelta).length} activas)'),
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
child:Container(height:4,color:AppColors.dorado)),
actions:[
Switch(value:_soloActivas,onChanged:(v){setState(()=>_soloActivas=v);_load();},
activeColor:AppColors.dorado),
IconButton(icon:const Icon(Icons.refresh),onPressed:_load),
]),
body:_alertas.isEmpty
?Center(child:Column(mainAxisAlignment:MainAxisAlignment.center,children:[
const Icon(Icons.check_circle,color:AppColors.verdeExito,size:48),
const SizedBox(height:8),
Text(_soloActivas?'Sin alertas activas':'Sin alertas registradas',
style:const TextStyle(color:AppColors.grisTexto))]))
:ListView.builder(padding:const EdgeInsets.all(12),
itemCount:_alertas.length,
itemBuilder:(_,i){
final a = _alertas[i];
final esIncidente = a.tipo.startsWith('INCIDENTE_');
return Card(margin:const EdgeInsets.only(bottom:8),
color:a.resuelta?Colors.grey.shade50:null,
child:ListTile(
leading:CircleAvatar(backgroundColor:a.resuelta?Colors.grey:_color(a.tipo),
child:Icon(_icon(a.tipo),color:Colors.white,size:18)),
title:Row(children:[
if(esIncidente) Container(margin:const EdgeInsets.only(right:6),
padding:const EdgeInsets.symmetric(horizontal:6,vertical:2),
decoration:BoxDecoration(color:AppColors.moradoConductor.withOpacity(0.1),
borderRadius:BorderRadius.circular(8)),
child:const Text('CONDUCTOR',style:TextStyle(fontSize:9,color:AppColors.moradoConductor,fontWeight:FontWeight.bold))),
Expanded(child:Text('${a.tipo.replaceAll('_',' ')}${a.routeId}',
style:TextStyle(fontSize:12,fontWeight:FontWeight.bold,
color:a.resuelta?AppColors.grisTexto:AppColors.negroTexto))),
]),
subtitle:Text(a.mensaje,style:const TextStyle(fontSize:11)),
trailing:a.resuelta
?const Icon(Icons.check_circle,color:AppColors.verdeExito,size:20)
:TextButton(
onPressed:()async{ await DbHelper.resolverAlerta(a.id!); await _load(); },
style:TextButton.styleFrom(foregroundColor:AppColors.verdeAdmin),
child:const Text('Resolver',style:TextStyle(fontSize:11))),
));
}),
);
}
// ── Widgets ───────────────────────────────────────────────────────────────
class _Stat extends StatelessWidget {
final String label,value; final IconData icon; final Color color;
const _Stat(this.label,this.value,this.icon,this.color);
@override
Widget build(BuildContext context) => Expanded(child:Card(
child:Padding(padding:const EdgeInsets.all(14),child:Row(children:[
Icon(icon,color:color,size:28),
const SizedBox(width:10),
Column(crossAxisAlignment:CrossAxisAlignment.start,children:[
Text(value,style:TextStyle(fontSize:22,fontWeight:FontWeight.bold,color:color)),
Text(label,style:const TextStyle(fontSize:11,color:AppColors.grisTexto)),
]),
]))));
}
class _AdminBanner extends StatelessWidget {
final AppNotification notif; final VoidCallback onDismiss;
const _AdminBanner({required this.notif,required this.onDismiss});
@override
Widget build(BuildContext context) => Material(color:Colors.transparent,
child:Container(margin:const EdgeInsets.all(10),
decoration:BoxDecoration(
color:notif.event==NotifEvent.routeCancelled?AppColors.rojoError:AppColors.rojoError,
borderRadius:BorderRadius.circular(12),
boxShadow:const[BoxShadow(color:Colors.black26,blurRadius:6)]),
child:Padding(padding:const EdgeInsets.all(12),child:Row(children:[
const Icon(Icons.admin_panel_settings,color:Colors.white,size:22),
const SizedBox(width:8),
Expanded(child:Column(crossAxisAlignment:CrossAxisAlignment.start,mainAxisSize:MainAxisSize.min,children:[
Text(notif.title,style:const TextStyle(color:Colors.white,fontWeight:FontWeight.bold,fontSize:13)),
Text(notif.body,style:const TextStyle(color:Colors.white70,fontSize:11),
maxLines:2,overflow:TextOverflow.ellipsis),
])),
IconButton(icon:const Icon(Icons.close,color:Colors.white,size:18),onPressed:onDismiss),
]))));
}

View File

@@ -0,0 +1,175 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:camera/camera.dart';
import 'package:tflite_flutter/tflite_flutter.dart';
import 'package:image/image.dart' as img;
import '../../core/app_colors.dart';
List<CameraDescription> _cameras = [];
class AiCameraScreen extends StatefulWidget {
const AiCameraScreen({super.key});
@override State<AiCameraScreen> createState() => _AiCameraScreenState();
}
class _AiCameraScreenState extends State<AiCameraScreen> {
CameraController? _cam;
Interpreter? _interpreter;
bool _processing = false;
String _result = 'Apunta a un residuo y escanea';
String _confidence = '';
bool _modelLoaded = false;
// 0=Orgánico, 1=Inorgánico (según waste_classification_model)
final _labels = ['Residuo Orgánico ♻️', 'Residuo Inorgánico 🗑️'];
final _labelColors = [AppColors.verdeExito, AppColors.naranjaAlerta];
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
try {
_cameras = await availableCameras();
} catch (_) {}
await _initCamera();
await _loadModel();
}
Future<void> _initCamera() async {
if (_cameras.isEmpty) return;
_cam = CameraController(_cameras[0], ResolutionPreset.medium, enableAudio: false);
try {
await _cam!.initialize();
if (mounted) setState(() {});
} catch (_) {}
}
Future<void> _loadModel() async {
try {
_interpreter = await Interpreter.fromAsset('assets/models/waste_model.tflite');
setState(() => _modelLoaded = true);
} catch (e) {
setState(() => _result = '⚠️ Modelo no encontrado.\nAgrega waste_model.tflite a assets/models/');
}
}
Future<void> _classify() async {
if (_cam == null || !_cam!.value.isInitialized || _processing || !_modelLoaded) return;
setState(() { _processing = true; _result = 'Analizando...'; _confidence = ''; });
try {
final pic = await _cam!.takePicture();
final raw = await File(pic.path).readAsBytes();
img.Image? decoded = img.decodeImage(raw);
if (decoded == null) throw Exception('No se pudo decodificar');
final resized = img.copyResize(decoded, width: 150, height: 150);
var input = List.generate(1, (_) =>
List.generate(150, (_) => List.generate(150, (_) => List.generate(3, (_) => 0.0))));
for (int y = 0; y < 150; y++) {
for (int x = 0; x < 150; x++) {
final px = resized.getPixel(x, y);
input[0][y][x][0] = px.r / 255.0;
input[0][y][x][1] = px.g / 255.0;
input[0][y][x][2] = px.b / 255.0;
}
}
var output = List.filled(2, 0.0).reshape([1, 2]);
_interpreter!.run(input, output);
final pred = List<double>.from(output[0]);
final maxIdx = pred[0] > pred[1] ? 0 : 1;
final conf = pred[maxIdx] * 100;
await File(pic.path).delete();
setState(() {
_result = _labels[maxIdx];
_confidence = 'Confianza: ${conf.toStringAsFixed(1)}%';
});
} catch (e) {
setState(() => _result = 'Error en análisis');
} finally {
setState(() => _processing = false);
}
}
@override
void dispose() {
_cam?.dispose();
_interpreter?.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
final resultColor = _result.contains('Orgánico') ? AppColors.verdeExito
: _result.contains('Inorgánico') ? AppColors.naranjaAlerta
: AppColors.guindaPrimary;
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
title: const Text('Clasificador IA de Residuos'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
),
body: Column(children: [
// Visor cámara
Expanded(flex: 4,
child: Container(margin: const EdgeInsets.all(14),
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.guindaPrimary, width: 3)),
child: _cam != null && _cam!.value.isInitialized
? CameraPreview(_cam!)
: const Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.camera_alt, color: Colors.white54, size: 48),
SizedBox(height: 8),
Text('Iniciando cámara...', style: TextStyle(color: Colors.white54)),
])),
),
),
// Panel resultado
Expanded(flex: 2,
child: Container(width: double.infinity,
decoration: BoxDecoration(color: AppColors.guindaPrimary.withOpacity(0.06),
borderRadius: const BorderRadius.vertical(top: Radius.circular(28))),
padding: const EdgeInsets.all(20),
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Text(_result, textAlign: TextAlign.center,
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: resultColor)),
if (_confidence.isNotEmpty) ...[
const SizedBox(height: 6),
Text(_confidence, style: const TextStyle(fontSize: 16, color: Colors.black54, fontWeight: FontWeight.w500)),
],
const SizedBox(height: 16),
if (!_modelLoaded)
Container(padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: Colors.orange.shade50, borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.shade300)),
child: const Text(' Para usar la IA, coloca waste_model.tflite en assets/models/',
textAlign: TextAlign.center, style: TextStyle(fontSize: 11))),
if (_modelLoaded)
SizedBox(width: double.infinity, height: 50,
child: ElevatedButton.icon(
onPressed: _processing ? null : _classify,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14))),
icon: _processing
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Icon(Icons.center_focus_strong),
label: Text(_processing ? 'Procesando...' : 'Escanear Residuo',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
)),
]),
),
),
]),
);
}
}

View File

@@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
import '../../core/app_colors.dart';
import 'ai_camera_screen.dart';
class CitizenGuiaScreen extends StatelessWidget {
const CitizenGuiaScreen({super.key});
static const _cats = [
_Cat(Icons.grass,Color(0xFF2E7D32),'Orgánicos','Restos de comida, jardín','🟢 Bolsa Verde',[
'Frutas y verduras','Cáscaras de huevo','Posos de café y té',
'Restos de comida preparada','Pasto y hojas','Cáscaras de semillas'],
['Aceites en exceso','Carnes en grandes cantidades']),
_Cat(Icons.recycling,Color(0xFF1565C0),'Reciclables','Papel, plástico, vidrio, metal','🔵 Bolsa Azul',[
'Botellas PET','Latas de aluminio','Cartón y papel limpio',
'Vidrio (botellas, frascos)','Periódico y revistas'],
['Vidrio roto sin envolver','Papel sucio o mojado','Unicel']),
_Cat(Icons.delete,Color(0xFF757575),'No Reciclables','Residuos que no se reusan','⚫ Bolsa Negra',[
'Pañales desechables','Toallas sanitarias','Papel higiénico usado',
'Colillas de cigarro','Cerámica rota'],['Baterías','Medicamentos','Aceite usado']),
_Cat(Icons.warning_amber,Color(0xFFC62828),'Peligrosos','Requieren manejo especial','🔴 Separado',[
'Agujas y jeringas','Medicamentos vencidos','Pilas y baterías',
'Aceite de cocina usado','Pintura y solventes'],[],isWarn:true),
_Cat(Icons.devices_other,Color(0xFFE65100),'Electrónicos (RAEE)','Aparatos electrónicos','🟠 Punto de acopio',[
'Celulares viejos','Computadoras','Televisiones',
'Focos ahorradores','Cables y cargadores'],[],isSpecial:true),
];
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(automaticallyImplyLeading:false,
backgroundColor:AppColors.guindaPrimary, foregroundColor:Colors.white,
title:const Text('Guía de Separación'),
actions:[IconButton(icon:const Icon(Icons.camera_alt),
tooltip:'Clasificar con IA',
onPressed:()=>Navigator.push(context,MaterialPageRoute(builder:(_)=>const AiCameraScreen())))],
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
child:Container(height:4,color:AppColors.dorado))),
body:Column(children:[
Container(width:double.infinity,
color:AppColors.verdeExito.withOpacity(0.1),
padding:const EdgeInsets.symmetric(horizontal:16,vertical:8),
child:Row(children:[
const Icon(Icons.offline_bolt,color:AppColors.verdeExito,size:16),
const SizedBox(width:6),
const Text('Disponible sin conexión a internet',
style:TextStyle(color:AppColors.verdeExito,fontSize:12,fontWeight:FontWeight.w500)),
const Spacer(),
TextButton.icon(icon:const Icon(Icons.camera_alt,size:14),
label:const Text('Clasificar IA',style:TextStyle(fontSize:12)),
style:TextButton.styleFrom(foregroundColor:AppColors.guindaPrimary),
onPressed:()=>Navigator.push(context,MaterialPageRoute(builder:(_)=>const AiCameraScreen()))),
])),
// Importancia de separar
Container(margin:const EdgeInsets.fromLTRB(12,8,12,0),
padding:const EdgeInsets.all(12),
decoration:BoxDecoration(color:Colors.green.shade50,borderRadius:BorderRadius.circular(8),
border:Border.all(color:Colors.green.shade200)),
child:const Column(crossAxisAlignment:CrossAxisAlignment.start, children:[
Text('¿Por qué separar tu basura?',style:TextStyle(fontWeight:FontWeight.bold,color:Color(0xFF2E7D32))),
SizedBox(height:6),
Text('♻️ El 60% de los residuos en México pueden reciclarse o compostarse, pero solo el 5% lo hace.\n'
'🌱 Separar correctamente reduce la contaminación del suelo y agua, genera empleos verdes '
'y disminuye los gases de efecto invernadero producidos en rellenos sanitarios.',
style:TextStyle(fontSize:12,color:Colors.black87)),
])),
Expanded(child:ListView.builder(
padding:const EdgeInsets.all(12),
itemCount:_cats.length,
itemBuilder:(ctx,i)=>_CatCard(cat:_cats[i]))),
]),
);
}
class _Cat {
final IconData icon; final Color color; final String title, subtitle, bolsa;
final List<String> items, noItems;
final bool isWarn, isSpecial;
const _Cat(this.icon,this.color,this.title,this.subtitle,this.bolsa,
this.items,this.noItems,{this.isWarn=false,this.isSpecial=false});
}
class _CatCard extends StatefulWidget {
final _Cat cat;
const _CatCard({super.key, required this.cat});
@override State<_CatCard> createState() => _CatCardState();
}
class _CatCardState extends State<_CatCard> {
bool _open = false;
@override
Widget build(BuildContext context) {
final c = widget.cat;
return Card(margin:const EdgeInsets.only(bottom:10),elevation:2,
shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(10),
side:BorderSide(color:c.color.withOpacity(0.3))),
child:InkWell(borderRadius:BorderRadius.circular(10),
onTap:()=>setState(()=>_open=!_open),
child:Column(children:[
Container(decoration:BoxDecoration(color:c.color.withOpacity(0.1),
borderRadius:BorderRadius.vertical(top:const Radius.circular(10),
bottom:_open?Radius.zero:const Radius.circular(10))),
padding:const EdgeInsets.all(14),
child:Row(children:[
Container(width:40,height:40,decoration:BoxDecoration(color:c.color,borderRadius:BorderRadius.circular(8)),
child:Icon(c.icon,color:Colors.white,size:22)),
const SizedBox(width:10),
Expanded(child:Column(crossAxisAlignment:CrossAxisAlignment.start, children:[
Text(c.title,style:TextStyle(fontWeight:FontWeight.bold,fontSize:15,color:c.color)),
Text(c.subtitle,style:const TextStyle(color:AppColors.grisTexto,fontSize:11)),
Text(c.bolsa,style:TextStyle(fontSize:11,fontWeight:FontWeight.w600,color:c.color)),
])),
Icon(_open?Icons.expand_less:Icons.expand_more,color:c.color),
])),
if (_open) Padding(padding:const EdgeInsets.fromLTRB(14,0,14,14),
child:Column(crossAxisAlignment:CrossAxisAlignment.start, children:[
const SizedBox(height:8),
Text('✅ Qué va aquí:',style:TextStyle(fontWeight:FontWeight.bold,color:c.color,fontSize:12)),
const SizedBox(height:4),
...c.items.map((e)=>Padding(padding:const EdgeInsets.symmetric(vertical:2),
child:Row(children:[Icon(Icons.check_circle_outline,size:13,color:c.color),
const SizedBox(width:6),Text(e,style:const TextStyle(fontSize:12))]))),
if (c.noItems.isNotEmpty) ...[
const SizedBox(height:8),
const Text('❌ NO incluir:',style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.rojoError,fontSize:12)),
...c.noItems.map((e)=>Padding(padding:const EdgeInsets.symmetric(vertical:2),
child:Row(children:[const Icon(Icons.cancel_outlined,size:13,color:AppColors.rojoError),
const SizedBox(width:6),Text(e,style:const TextStyle(fontSize:12,color:AppColors.rojoError))]))),
],
if (c.isSpecial) ...[
const SizedBox(height:8),
Container(padding:const EdgeInsets.all(8),
decoration:BoxDecoration(color:Colors.orange.shade50,borderRadius:BorderRadius.circular(6),
border:Border.all(color:Colors.orange.shade200)),
child:const Text('📍 Lleva a puntos de acopio autorizados por el municipio.',
style:TextStyle(fontSize:11))),
],
if (c.isWarn) ...[
const SizedBox(height:8),
Container(padding:const EdgeInsets.all(8),
decoration:BoxDecoration(color:Colors.red.shade50,borderRadius:BorderRadius.circular(6),
border:Border.all(color:Colors.red.shade200)),
child:const Text('⚠️ NUNCA mezcles residuos peligrosos con basura común.',
style:TextStyle(fontSize:11))),
],
])),
])));
}
}

View File

@@ -0,0 +1,448 @@
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 '../../widgets/route_map_widget.dart';
import 'citizen_guia_screen.dart';
import 'citizen_reporte_screen.dart';
class CitizenHomeScreen extends StatefulWidget {
const CitizenHomeScreen({super.key});
@override State<CitizenHomeScreen> createState() => _CitizenHomeScreenState();
}
class _CitizenHomeScreenState extends State<CitizenHomeScreen> {
int _tab = 0;
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthService>();
final sim = context.watch<RouteSimulatorService>();
final dom = auth.primaryDomicilio; // domicilio del ciudadano
final last = dom != null ? sim.getNotificationForRoute(dom.routeId) : null;
final tabs = [
_HomeTab(auth: auth, sim: sim),
const CitizenGuiaScreen(),
const CitizenReporteScreen(),
];
return Scaffold(
backgroundColor: AppColors.grisFondo,
body: Stack(children: [
tabs[_tab],
if (last != null)
Positioned(
top: MediaQuery.of(context).padding.top + 8, left: 0, right: 0,
child: _NotifBanner(notif: last, onDismiss: () => sim.dismissRouteNotification(dom?.routeId ?? '')),
),
]),
bottomNavigationBar: NavigationBar(
selectedIndex: _tab,
onDestinationSelected: (i) => setState(() => _tab = i),
backgroundColor: Colors.white,
indicatorColor: AppColors.guindaPrimary.withOpacity(0.15),
destinations: const [
NavigationDestination(icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home, color: AppColors.guindaPrimary), label: 'Inicio'),
NavigationDestination(icon: Icon(Icons.eco_outlined),
selectedIcon: Icon(Icons.eco, color: AppColors.guindaPrimary), label: 'Guía'),
NavigationDestination(icon: Icon(Icons.report_outlined),
selectedIcon: Icon(Icons.report, color: AppColors.guindaPrimary), label: 'Reportar'),
],
),
);
}
}
// ── Tab principal (StatefulWidget para cargar status de ruta) ─────────────
class _HomeTab extends StatefulWidget {
final AuthService auth;
final RouteSimulatorService sim;
const _HomeTab({required this.auth, required this.sim});
@override State<_HomeTab> createState() => _HomeTabState();
}
class _HomeTabState extends State<_HomeTab> {
RouteStatusModel? _routeStatus;
@override
void initState() {
super.initState();
_loadStatus();
}
Future<void> _loadStatus() async {
final dom = widget.auth.primaryDomicilio;
if (dom == null) return;
final s = await DbHelper.getRouteStatus(dom.routeId);
if (mounted) setState(() => _routeStatus = s);
}
bool get _isRouteProblematic {
final s = _routeStatus?.status ?? RouteStatus.enRuta;
return s == RouteStatus.cancelada ||
s == RouteStatus.fallaMecanica ||
s == RouteStatus.retrasada;
}
@override
Widget build(BuildContext context) {
final dom = widget.auth.primaryDomicilio;
final routeId = dom?.routeId ?? '';
final route = dom != null ? getRouteById(dom.routeId) : null;
final isTruckClose = widget.sim.isTruckClose(routeId);
final status = _routeStatus?.status ?? RouteStatus.enRuta;
return RefreshIndicator(
onRefresh: _loadStatus,
child: CustomScrollView(slivers: [
SliverAppBar(
expandedHeight: 120, pinned: true,
backgroundColor: AppColors.guindaPrimary,
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
flexibleSpace: FlexibleSpaceBar(
background: Container(
color: AppColors.guindaPrimary,
padding: const EdgeInsets.fromLTRB(20, 50, 20, 16),
child: Row(children: [
const Icon(Icons.delete_sweep_rounded, color: AppColors.dorado, size: 30),
const SizedBox(width: 12),
Expanded(child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Hola, ${widget.auth.currentUser?.nombre.split(' ').first ?? ''}',
style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)),
const Text('Celaya Limpia', style: TextStyle(color: AppColors.dorado, fontSize: 12)),
],
)),
IconButton(
icon: const Icon(Icons.logout, color: Colors.white70),
onPressed: () async {
await widget.auth.logout();
if (context.mounted) Navigator.pushReplacementNamed(context, '/login');
},
),
]),
),
),
),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(delegate: SliverChildListDelegate([
// ── Si la ruta tiene problema → mostrar alerta en vez de ETA/mapa
if (_isRouteProblematic) ...[
_RouteStatusBanner(status: _routeStatus!),
const SizedBox(height: 12),
] else ...[
// ETA Card normal
_EtaCard(sim: widget.sim, routeId: routeId, dom: dom, route: route),
const SizedBox(height: 12),
// Mapa solo cuando camión está cerca
if (isTruckClose && route != null) ...[
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.shade300),
),
child: const Row(children: [
Icon(Icons.location_on, color: Colors.orange, size: 18),
SizedBox(width: 6),
Expanded(child: Text('📍 El camión está cerca — mapa activado',
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.orange, fontSize: 12))),
]),
),
const SizedBox(height: 8),
RouteMapWidget(route: route, simulator: widget.sim, height: 220),
const SizedBox(height: 12),
],
],
// Aviso privacidad
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.amber.shade50, borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.amber.shade300),
),
child: const Row(children: [
Icon(Icons.shield_outlined, color: Colors.amber, size: 18),
SizedBox(width: 6),
Expanded(child: Text('🔒 Solo ves la información de tu ruta asignada.',
style: TextStyle(fontSize: 11, color: Colors.black87))),
]),
),
const SizedBox(height: 12),
// Info domicilio
if (dom != null)
Card(child: Padding(
padding: const EdgeInsets.all(14),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Row(children: [
Icon(Icons.location_on, color: AppColors.guindaPrimary, size: 16),
SizedBox(width: 6),
Text('Mi Domicilio', style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)),
]),
const Divider(),
Text(dom.calle, style: const TextStyle(fontSize: 13)),
Text('${dom.colonia}${dom.routeId}',
style: const TextStyle(color: AppColors.grisTexto, fontSize: 12)),
Text(dom.horarioEstimado,
style: const TextStyle(color: AppColors.grisTexto, fontSize: 11)),
]),
)),
// Historial notificaciones
if (widget.sim.history.isNotEmpty) ...[
const SizedBox(height: 12),
Card(child: Padding(
padding: const EdgeInsets.all(14),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('Alertas recientes',
style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)),
const Divider(),
...widget.sim.history.take(4).map((n) {
final color = n.event == NotifEvent.truckProximity
? AppColors.naranjaAlerta
: n.event == NotifEvent.routeCompleted
? AppColors.verdeExito
: n.event == NotifEvent.routeCancelled
? AppColors.rojoError
: AppColors.azulInfo;
final icon = n.event == NotifEvent.truckProximity
? Icons.warning_amber
: n.event == NotifEvent.routeCompleted
? Icons.check_circle
: n.event == NotifEvent.routeCancelled
? Icons.cancel
: Icons.local_shipping;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 6),
Expanded(child: Text(n.title,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500))),
Text(
'${n.timestamp.hour.toString().padLeft(2, '0')}:${n.timestamp.minute.toString().padLeft(2, '0')}',
style: const TextStyle(fontSize: 10, color: AppColors.grisTexto),
),
]),
);
}),
]),
)),
],
const SizedBox(height: 80),
])),
),
]),
);
}
}
// ── Banner de ruta con problema ───────────────────────────────────────────
class _RouteStatusBanner extends StatelessWidget {
final RouteStatusModel status;
const _RouteStatusBanner({required this.status});
@override
Widget build(BuildContext context) {
final isCancelled = status.status == RouteStatus.cancelada;
final isFalla = status.status == RouteStatus.fallaMecanica;
final isRetrasada = status.status == RouteStatus.retrasada;
final color = isCancelled ? AppColors.rojoError
: isFalla ? Colors.red.shade800
: AppColors.naranjaAlerta;
final icon = isCancelled ? Icons.cancel
: isFalla ? Icons.build
: Icons.access_time;
final titulo = isCancelled ? '❌ Ruta Cancelada Hoy'
: isFalla ? '🔧 Falla Mecánica en Servicio'
: '⏱️ Servicio con Retraso';
final descripcion = isCancelled
? 'El servicio de recolección de tu colonia no se realizará hoy. Favor de guardar tus residuos para la próxima jornada.'
: isFalla
? 'El camión asignado a tu sector presentó una falla mecánica. El Ayuntamiento está atendiendo la situación.'
: 'El camión de tu sector presenta un retraso en su recorrido. El servicio se realizará, pero con demora.';
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// Alerta principal
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(12),
boxShadow: [BoxShadow(color: color.withOpacity(0.4), blurRadius: 8, offset: const Offset(0, 4))],
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(icon, color: Colors.white, size: 26),
const SizedBox(width: 10),
Expanded(child: Text(titulo,
style: const TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold))),
]),
const SizedBox(height: 10),
Text(descripcion, style: const TextStyle(color: Colors.white, fontSize: 13, height: 1.4)),
]),
),
// Mensaje del administrador (posible solución)
if (status.mensaje != null && status.mensaje!.isNotEmpty) ...[
const SizedBox(height: 10),
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withOpacity(0.4)),
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(Icons.admin_panel_settings, color: color, size: 16),
const SizedBox(width: 6),
Text('Mensaje del Ayuntamiento',
style: TextStyle(fontWeight: FontWeight.bold, color: color, fontSize: 13)),
]),
const SizedBox(height: 6),
Text(status.mensaje!,
style: const TextStyle(fontSize: 13, color: AppColors.negroTexto, height: 1.4)),
]),
),
],
// Consejo ciudadano
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('💡 Recomendaciones:',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: AppColors.grisTexto)),
const SizedBox(height: 4),
if (isCancelled)
const Text('• Guarda tus bolsas en un lugar cerrado\n'
'• No dejes residuos en la acera\n'
'• Revisa la app mañana para el horario actualizado',
style: TextStyle(fontSize: 12, color: AppColors.grisTexto)),
if (isFalla)
const Text('• Espera confirmación del Ayuntamiento\n'
'• Puede enviarse una unidad de reemplazo\n'
'• Revisa las alertas en esta pantalla',
style: TextStyle(fontSize: 12, color: AppColors.grisTexto)),
if (isRetrasada)
const Text('• Tu basura será recogida hoy, con demora\n'
'• Puedes sacar tus bolsas cuando recibas la alerta\n'
'• Recibirás notificación cuando el camión se acerque',
style: TextStyle(fontSize: 12, color: AppColors.grisTexto)),
]),
),
]);
}
}
// ── ETA Card ──────────────────────────────────────────────────────────────
class _EtaCard extends StatelessWidget {
final RouteSimulatorService sim;
final String routeId;
final dom; final route;
const _EtaCard({required this.sim, required this.routeId, required this.dom, required this.route});
@override
Widget build(BuildContext context) => Container(
decoration: BoxDecoration(
gradient: const LinearGradient(colors: [AppColors.guindaPrimary, AppColors.guindaDark],
begin: Alignment.topLeft, end: Alignment.bottomRight),
borderRadius: BorderRadius.circular(14),
boxShadow: [BoxShadow(color: AppColors.guindaDark.withOpacity(0.4),
blurRadius: 8, offset: const Offset(0, 4))],
),
padding: const EdgeInsets.all(18),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
const Icon(Icons.local_shipping, color: AppColors.dorado, size: 22),
const SizedBox(width: 8),
Expanded(child: Text(route?.name ?? 'Ruta asignada',
style: const TextStyle(color: AppColors.dorado, fontSize: 13, fontWeight: FontWeight.w600))),
]),
const SizedBox(height: 8),
Text(sim.getEtaText(routeId),
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
if (dom != null)
Text('${dom.horarioEstimado}',
style: const TextStyle(color: Colors.white60, fontSize: 11)),
const SizedBox(height: 10),
LinearProgressIndicator(
value: route != null
? (sim.getPositionIndex(routeId) + 1) / route.positions.length : 0,
backgroundColor: Colors.white24,
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.dorado),
),
]),
);
}
// ── Banner notificación ───────────────────────────────────────────────────
class _NotifBanner extends StatelessWidget {
final AppNotification notif; final VoidCallback onDismiss;
const _NotifBanner({required this.notif, required this.onDismiss});
@override
Widget build(BuildContext context) {
final color = notif.event == NotifEvent.truckProximity
? AppColors.naranjaAlerta
: notif.event == NotifEvent.routeCompleted
? AppColors.verdeExito
: notif.event == NotifEvent.routeCancelled
? AppColors.rojoError
: notif.event == NotifEvent.gpsLost
? Colors.red.shade800
: AppColors.azulInfo;
return Material(
color: Colors.transparent,
child: Container(
margin: const EdgeInsets.all(12),
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(12),
boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 8, offset: Offset(0, 4))]),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(children: [
const Icon(Icons.notifications_active, color: Colors.white, size: 24),
const SizedBox(width: 10),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, children: [
Text(notif.title, style: const TextStyle(color: Colors.white,
fontWeight: FontWeight.bold, fontSize: 13)),
Text(notif.body, style: const TextStyle(color: Colors.white70, fontSize: 11),
maxLines: 2, overflow: TextOverflow.ellipsis),
])),
IconButton(icon: const Icon(Icons.close, color: Colors.white, size: 18),
onPressed: onDismiss),
]),
),
),
);
}
}

View File

@@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/app_colors.dart';
import '../../database/db_helper.dart';
import '../../models/models.dart';
import '../../services/auth_service.dart';
class CitizenReporteScreen extends StatefulWidget {
const CitizenReporteScreen({super.key});
@override State<CitizenReporteScreen> createState() => _CitizenReporteScreenState();
}
class _CitizenReporteScreenState extends State<CitizenReporteScreen> {
String _tipo = 'CAMION_NO_PASO';
final _desc = TextEditingController();
int _calif = 5;
bool _loading = false, _sent = false;
List<ReporteModel> _reportes = [];
static const _tipos = {
'CAMION_NO_PASO':'🚛 El camión no pasó',
'RETRASO':'⏱️ Retraso significativo',
'RESIDUOS_NO_RECOGIDOS':'🗑️ Residuos no recogidos',
'OTRO':'📝 Otro motivo',
};
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final auth = context.read<AuthService>();
if (auth.currentUser == null) return;
final r = await DbHelper.getReportesByUser(auth.currentUser!.id!);
if (mounted) setState(() => _reportes = r);
}
Future<void> _send() async {
final auth = context.read<AuthService>();
if (auth.currentUser == null || _desc.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Describe el problema'), backgroundColor: AppColors.rojoError)); return;
}
setState(() => _loading = true);
await DbHelper.insertReporte(ReporteModel(
userId: auth.currentUser!.id!, tipo: _tipo, descripcion: _desc.text.trim(),
colonia: auth.primaryDomicilio?.colonia ?? '',
routeId: auth.primaryDomicilio?.routeId ?? '',
fecha: DateTime.now().toIso8601String(), calificacion: _calif,
));
await _load();
if (!mounted) return;
setState(() { _loading = false; _sent = true; _desc.clear(); });
await Future.delayed(const Duration(seconds: 2));
if (mounted) setState(() => _sent = false);
}
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(automaticallyImplyLeading: false,
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
title: const Text('Reportar Incidencia'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado))),
body: _sent ? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
const Icon(Icons.check_circle, color: AppColors.verdeExito, size: 64),
const SizedBox(height: 12),
const Text('¡Reporte enviado!', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: AppColors.verdeExito)),
const Text('El Ayuntamiento lo revisará pronto.', style: TextStyle(color: AppColors.grisTexto)),
])) : SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(children: [
Card(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(padding: const EdgeInsets.all(16), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('Nueva Incidencia', style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.guindaPrimary, fontSize: 16)),
const SizedBox(height: 12),
const Text('Tipo:', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
..._tipos.entries.map((e) => RadioListTile<String>(dense: true, value: e.key,
groupValue: _tipo, title: Text(e.value, style: const TextStyle(fontSize: 13)),
activeColor: AppColors.guindaPrimary,
onChanged: (v) => setState(() => _tipo = v!))),
const SizedBox(height: 8),
DropdownButtonFormField<int>(value: _calif,
decoration: const InputDecoration(labelText: 'Calificación', border: OutlineInputBorder()),
items: [5,4,3,2,1].map((n) => DropdownMenuItem(value: n,
child: Text(['⭐⭐⭐⭐⭐ Excelente','⭐⭐⭐⭐ Bueno','⭐⭐⭐ Regular','⭐⭐ Malo','⭐ Muy malo'][5-n]))).toList(),
onChanged: (v) => setState(() => _calif = v!)),
const SizedBox(height: 10),
TextField(controller: _desc, maxLines: 3,
decoration: const InputDecoration(hintText: 'Describe el problema...',
border: OutlineInputBorder(), filled: true, fillColor: Colors.white)),
const SizedBox(height: 14),
SizedBox(width: double.infinity, height: 48,
child: ElevatedButton.icon(onPressed: _loading ? null : _send,
style: ElevatedButton.styleFrom(backgroundColor: AppColors.guindaPrimary,
foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
icon: _loading ? const SizedBox(width: 18, height: 18,
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) : const Icon(Icons.send),
label: const Text('ENVIAR REPORTE', style: TextStyle(fontWeight: FontWeight.bold)))),
]))),
if (_reportes.isNotEmpty) ...[
const SizedBox(height: 16),
const Align(alignment: Alignment.centerLeft,
child: Text('Mis Reportes', style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.guindaPrimary, fontSize: 15))),
const SizedBox(height: 8),
..._reportes.map((r) => Card(margin: const EdgeInsets.only(bottom: 6),
child: ListTile(dense: true,
leading: const CircleAvatar(backgroundColor: AppColors.guindaPrimary, radius: 16,
child: Icon(Icons.report, color: Colors.white, size: 16)),
title: Text(_tipos[r.tipo] ?? r.tipo, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)),
subtitle: Text(r.descripcion, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11)),
trailing: Container(padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(color: AppColors.naranjaAlerta.withOpacity(0.15), borderRadius: BorderRadius.circular(10)),
child: Text(r.estado, style: const TextStyle(fontSize: 9, color: AppColors.naranjaAlerta, fontWeight: FontWeight.bold)))))),
],
])),
);
@override void dispose() { _desc.dispose(); super.dispose(); }
}

View File

@@ -0,0 +1,456 @@
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 '../../widgets/route_map_widget.dart';
class DriverHomeScreen extends StatefulWidget {
const DriverHomeScreen({super.key});
@override State<DriverHomeScreen> createState() => _DriverHomeScreenState();
}
class _DriverHomeScreenState extends State<DriverHomeScreen> {
int _tab = 0;
List<AssignmentModel> _assignments = [];
String? _todayRouteId;
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final auth = context.read<AuthService>();
if (auth.currentUser == null) return;
final list = await DbHelper.getAsignacionesByConductor(auth.currentUser!.id!);
final today = _todayDia();
setState(() {
_assignments = list;
final match = list.where((a) => a.diaSemana == today);
_todayRouteId = match.isNotEmpty ? match.first.routeId : null;
});
if (_todayRouteId != null) {
context.read<RouteSimulatorService>().startRoute(_todayRouteId!);
}
}
String _todayDia() {
const d = ['','LUNES','MARTES','MIERCOLES','JUEVES','VIERNES','SABADO','DOMINGO'];
return d[DateTime.now().weekday];
}
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthService>();
final sim = context.watch<RouteSimulatorService>();
final route = _todayRouteId != null ? getRouteById(_todayRouteId!) : null;
// Solo notificaciones de la ruta actual del conductor
final lastNotif = _todayRouteId != null
? sim.getNotificationForRoute(_todayRouteId!) : null;
final tabs = [
_DriverMainTab(auth:auth, sim:sim, route:route,
assignments:_assignments, todayRouteId:_todayRouteId, onRefresh:_load),
if (route != null) _DriverMapTab(route:route, sim:sim)
else const Center(child:Text('Sin ruta hoy')),
_DriverReportesTab(conductorId:auth.currentUser?.id, todayRouteId:_todayRouteId),
];
return Scaffold(
body: Stack(children:[
tabs[_tab],
if (lastNotif != null)
Positioned(top:MediaQuery.of(context).padding.top+8, left:0, right:0,
child:_NotifBanner(notif:lastNotif,
onDismiss:()=>sim.dismissRouteNotification(_todayRouteId??''))),
]),
bottomNavigationBar: NavigationBar(
selectedIndex: _tab,
onDestinationSelected: (i) => setState(()=>_tab=i),
backgroundColor: Colors.white,
indicatorColor: AppColors.moradoConductor.withOpacity(0.15),
destinations: const [
NavigationDestination(icon:Icon(Icons.dashboard_outlined),
selectedIcon:Icon(Icons.dashboard,color:AppColors.moradoConductor),label:'Mi Ruta'),
NavigationDestination(icon:Icon(Icons.map_outlined),
selectedIcon:Icon(Icons.map,color:AppColors.moradoConductor),label:'Mapa'),
NavigationDestination(icon:Icon(Icons.report_problem_outlined),
selectedIcon:Icon(Icons.report_problem,color:AppColors.moradoConductor),label:'Incidente'),
],
),
);
}
}
// ── Tab principal ─────────────────────────────────────────────────────────
class _DriverMainTab extends StatefulWidget {
final AuthService auth; final RouteSimulatorService sim;
final route; final assignments; final todayRouteId; final VoidCallback onRefresh;
const _DriverMainTab({required this.auth, required this.sim, required this.route,
required this.assignments, required this.todayRouteId, required this.onRefresh});
@override State<_DriverMainTab> createState() => _DriverMainTabState();
}
class _DriverMainTabState extends State<_DriverMainTab> {
List<ReporteModel> _ciudadanoReportes = [];
@override void initState() { super.initState(); _loadReportes(); }
Future<void> _loadReportes() async {
if (widget.todayRouteId == null) return;
final all = await DbHelper.getAllReportes();
final filtered = all.where((r) => r.routeId == widget.todayRouteId).toList();
if (mounted) setState(() => _ciudadanoReportes = filtered.take(5).toList());
}
@override
Widget build(BuildContext context) {
final posIdx = widget.todayRouteId != null
? widget.sim.getPositionIndex(widget.todayRouteId!) : 0;
final gpsOk = widget.todayRouteId != null
? widget.sim.isGpsActive(widget.todayRouteId!) : true;
return CustomScrollView(slivers:[
SliverAppBar(pinned:true, backgroundColor:AppColors.moradoConductor, 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 ?? ''}',
style:const TextStyle(fontSize:16,fontWeight:FontWeight.bold)),
actions:[IconButton(icon:const Icon(Icons.logout),
onPressed:()async{ await widget.auth.logout();
if(context.mounted) Navigator.pushReplacementNamed(context,'/login');})]),
SliverPadding(padding:const EdgeInsets.all(14),sliver:SliverList(delegate:SliverChildListDelegate([
// Ruta de hoy
Card(color:AppColors.moradoConductor.withOpacity(0.08),
shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(12),
side:BorderSide(color:AppColors.moradoConductor.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 SizedBox(width:8),
Text('Hoy — ${_todayLabel()}',
style:const TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor,fontSize:15)),
]),
const Divider(),
if (widget.route != null)...[
Text(widget.route.name,style:const TextStyle(fontWeight:FontWeight.bold,fontSize:14)),
Text('Camión ${widget.route.truckId} • Turno: ${widget.route.turno}',
style:const TextStyle(color:AppColors.grisTexto,fontSize:12)),
const SizedBox(height:8),
Row(children:[
Icon(gpsOk?Icons.gps_fixed:Icons.gps_off,
color:gpsOk?AppColors.verdeExito:AppColors.rojoError,size:16),
const SizedBox(width:4),
Text(gpsOk?'GPS Activo':'⚠️ GPS Desactivado',
style:TextStyle(color:gpsOk?AppColors.verdeExito:AppColors.rojoError,
fontWeight:FontWeight.bold,fontSize:12)),
const Spacer(),
Text('Posición ${posIdx+1}/8',style:const TextStyle(color:AppColors.grisTexto,fontSize:12)),
]),
const SizedBox(height:8),
LinearProgressIndicator(value:(posIdx+1)/8,
backgroundColor:Colors.grey.shade300,
valueColor:const AlwaysStoppedAnimation<Color>(AppColors.moradoConductor)),
const SizedBox(height:6),
Text(widget.sim.getEtaText(widget.todayRouteId??''),
style:const TextStyle(fontSize:13,fontWeight:FontWeight.w500)),
] else
const Text('⚠️ Sin ruta asignada hoy.',style:TextStyle(color:AppColors.rojoError)),
]))),
const SizedBox(height:10),
// Instrucciones
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)),
const Divider(),
const Text('• Sigue la ruta asignada sin desviaciones\n'
'• Mantén el GPS activo en todo momento\n'
'• Reporta incidentes desde "Incidente"\n'
'• Si hay problema, el admin decidirá si se cancela o retrasa',
style:TextStyle(fontSize:12,color:AppColors.grisTexto)),
]))),
const SizedBox(height:10),
// Reportes ciudadanos de SU ruta
if (_ciudadanoReportes.isNotEmpty) ...[
Card(color:Colors.orange.shade50,
shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(10),
side:BorderSide(color:Colors.orange.shade200)),
child:Padding(padding:const EdgeInsets.all(12),child:Column(
crossAxisAlignment:CrossAxisAlignment.start, children:[
const Row(children:[
Icon(Icons.people,color:AppColors.naranjaAlerta,size:16),
SizedBox(width:6),
Text('Reportes de tu ruta hoy',
style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.naranjaAlerta,fontSize:13)),
]),
const Divider(),
..._ciudadanoReportes.map((r)=>Padding(
padding:const EdgeInsets.symmetric(vertical:3),
child:Row(children:[
const Icon(Icons.person_outline,size:12,color:AppColors.grisTexto),
const SizedBox(width:4),
Expanded(child:Text(r.descripcion,style:const TextStyle(fontSize:11),
maxLines:1,overflow:TextOverflow.ellipsis)),
]))),
]))),
const SizedBox(height:10),
],
// Horario LMV / MJS
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)),
const Divider(),
if (widget.assignments.isEmpty)
const Text('Sin asignaciones. Contacta al administrador.',
style:TextStyle(color:AppColors.grisTexto,fontSize:12))
else ...[
_scheduleGroup(widget.assignments,'LUNES','MIERCOLES','VIERNES',
'Lunes, Miércoles y Viernes'),
const SizedBox(height:8),
_scheduleGroup(widget.assignments,'MARTES','JUEVES','SABADO',
'Martes, Jueves y Sábado'),
],
]))),
const SizedBox(height:80),
]))),
]);
}
Widget _scheduleGroup(List<AssignmentModel> all, String d1, String d2, String d3, String label) {
AssignmentModel? found;
for (final dia in [d1,d2,d3]) {
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),
borderRadius:BorderRadius.circular(8),
border:Border.all(color:AppColors.moradoConductor.withOpacity(0.2))),
child:Row(children:[
const Icon(Icons.calendar_today,size:14,color:AppColors.moradoConductor),
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)),
child:Text('${found.routeId}${found.turno}',
style:const TextStyle(fontSize:11,color:Colors.white,fontWeight:FontWeight.bold)))
else
const Text('Sin asignar',style:TextStyle(fontSize:11,color:AppColors.grisTexto)),
]));
}
String _todayLabel() {
const d=['','Lunes','Martes','Miércoles','Jueves','Viernes','Sábado','Domingo'];
return d[DateTime.now().weekday];
}
}
// ── Tab mapa ──────────────────────────────────────────────────────────────
class _DriverMapTab extends StatelessWidget {
final route; final sim;
const _DriverMapTab({required this.route, required this.sim});
@override
Widget build(BuildContext context) => Scaffold(
appBar:AppBar(automaticallyImplyLeading:false,
backgroundColor:AppColors.moradoConductor,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))),
body:RouteMapWidget(route:route,simulator:sim,
height:MediaQuery.of(context).size.height,showFullRoute:true));
}
// ── Tab reporte incidente — usa routeId actual ────────────────────────────
class _DriverReportesTab extends StatefulWidget {
final int? conductorId;
final String? todayRouteId; // Ruta actual del conductor
const _DriverReportesTab({required this.conductorId, required this.todayRouteId});
@override State<_DriverReportesTab> createState() => _DriverReportesTabState();
}
class _DriverReportesTabState extends State<_DriverReportesTab> {
String _tipo = 'INCIDENTE_LLANTA';
final _desc = TextEditingController();
bool _loading = false, _sent = false;
List<AlertaModel> _misIncidentes = [];
static const _tipos = {
'INCIDENTE_LLANTA': '🔧 Llanta ponchada',
'INCIDENTE_MECANICA': '🔥 Falla mecánica',
'INCIDENTE_ACCIDENTE': '🚑 Accidente',
'INCIDENTE_CAMINO': '🚧 Camino bloqueado',
'INCIDENTE_COMBUSTIBLE':'⛽ Sin combustible',
'INCIDENTE_OTRO': '📝 Otro',
};
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final all = await DbHelper.getAlertas();
// Solo incidentes de la ruta actual del conductor
final mine = all.where((a) =>
a.tipo.startsWith('INCIDENTE_') &&
a.routeId == (widget.todayRouteId ?? '')).toList();
if (mounted) setState(() => _misIncidentes = mine);
}
Future<void> _enviar() async {
if (widget.todayRouteId == null) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content:Text('No tienes ruta asignada hoy'),
backgroundColor:AppColors.rojoError)); return;
}
if (_desc.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content:Text('Describe el incidente'),backgroundColor:AppColors.rojoError)); return;
}
setState(()=>_loading=true);
// Guardar el incidente asociado a la RUTA ACTUAL
await DbHelper.insertAlerta(AlertaModel(
tipo: _tipo,
routeId: widget.todayRouteId!, // ← ID de la ruta actual, no del conductor
mensaje: '${_tipos[_tipo]}: ${_desc.text.trim()}',
fecha: DateTime.now().toIso8601String(),
));
await _load();
if (!mounted) return;
setState(() { _loading=false; _sent=true; });
_desc.clear();
await Future.delayed(const Duration(seconds:2));
if (mounted) setState(()=>_sent=false);
}
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor:AppColors.grisFondo,
appBar:AppBar(automaticallyImplyLeading:false,
backgroundColor:AppColors.moradoConductor,foregroundColor:Colors.white,
title:const Text('Reportar Incidente'),
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
child:Container(height:4,color:AppColors.dorado))),
body: _sent
? const Center(child:Column(mainAxisAlignment:MainAxisAlignment.center,children:[
Icon(Icons.check_circle,color:AppColors.verdeExito,size:64),
SizedBox(height:12),
Text('¡Incidente reportado!',style:TextStyle(fontSize:18,fontWeight:FontWeight.bold,color:AppColors.verdeExito)),
Text('El administrador será notificado.',style:TextStyle(color:AppColors.grisTexto)),
]))
: SingleChildScrollView(padding:const EdgeInsets.all(16),child:Column(children:[
// Info ruta actual
if (widget.todayRouteId != null)
Container(margin:const EdgeInsets.only(bottom:12),
padding:const EdgeInsets.all(10),
decoration:BoxDecoration(color:AppColors.moradoConductor.withOpacity(0.08),
borderRadius:BorderRadius.circular(8),
border:Border.all(color:AppColors.moradoConductor.withOpacity(0.3))),
child:Row(children:[
const Icon(Icons.route,color:AppColors.moradoConductor,size:16),
const SizedBox(width:6),
Text('Incidente en: ${widget.todayRouteId}',
style:const TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor,fontSize:13)),
]))
else
Container(margin:const EdgeInsets.only(bottom:12),
padding:const EdgeInsets.all(10),
decoration:BoxDecoration(color:Colors.orange.shade50,borderRadius:BorderRadius.circular(8)),
child:const Text('⚠️ No tienes ruta asignada hoy',
style:TextStyle(color:AppColors.naranjaAlerta,fontWeight:FontWeight.bold))),
Card(shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(12)),
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)),
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,
onChanged:(v)=>setState(()=>_tipo=v!))),
const SizedBox(height:10),
const Text('Descripción',style:TextStyle(fontWeight:FontWeight.w600,fontSize:13)),
const SizedBox(height:6),
TextField(controller:_desc,maxLines:3,
decoration:const InputDecoration(hintText:'Describe qué pasó...',
border:OutlineInputBorder(),filled:true,fillColor:Colors.white)),
const SizedBox(height:12),
Container(padding:const EdgeInsets.all(10),
decoration:BoxDecoration(color:Colors.orange.shade50,
borderRadius:BorderRadius.circular(8),
border:Border.all(color:Colors.orange.shade200)),
child:const Text(
'⚠️ El administrador verá este incidente en tu ruta actual '
'y decidirá si continúa, se retrasa o se cancela.',
style:TextStyle(fontSize:11,color:Colors.black87))),
const SizedBox(height:14),
SizedBox(width:double.infinity,height:48,
child:ElevatedButton.icon(
onPressed:(_loading||widget.todayRouteId==null)?null:_enviar,
style:ElevatedButton.styleFrom(backgroundColor:AppColors.moradoConductor,
foregroundColor:Colors.white,
shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(8))),
icon:_loading?const SizedBox(width:18,height:18,
child:CircularProgressIndicator(color:Colors.white,strokeWidth:2))
:const Icon(Icons.send),
label:const Text('ENVIAR INCIDENTE',style:TextStyle(fontWeight:FontWeight.bold)))),
]))),
if (_misIncidentes.isNotEmpty)...[
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))),
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,
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)),
subtitle:Text(a.mensaje,maxLines:1,overflow:TextOverflow.ellipsis,
style:const TextStyle(fontSize:11)),
trailing:Icon(a.resuelta?Icons.check_circle:Icons.pending,
color:a.resuelta?AppColors.verdeExito:AppColors.naranjaAlerta,size:18)))),
],
])),
);
@override void dispose(){ _desc.dispose(); super.dispose(); }
}
// ── Notif banner conductor ────────────────────────────────────────────────
class _NotifBanner extends StatelessWidget {
final AppNotification notif; final VoidCallback onDismiss;
const _NotifBanner({required this.notif, required this.onDismiss});
@override
Widget build(BuildContext context) {
final color = notif.event==NotifEvent.gpsLost?Colors.red.shade800
:notif.event==NotifEvent.truckStopped?AppColors.naranjaAlerta
:notif.event==NotifEvent.routeCancelled?AppColors.rojoError
:AppColors.moradoConductor;
return Material(color:Colors.transparent,
child:Container(margin:const EdgeInsets.all(10),
decoration:BoxDecoration(color:color,borderRadius:BorderRadius.circular(12),
boxShadow:const[BoxShadow(color:Colors.black26,blurRadius:6)]),
child:Padding(padding:const EdgeInsets.all(12),child:Row(children:[
const Icon(Icons.notification_important,color:Colors.white,size:22),
const SizedBox(width:8),
Expanded(child:Column(crossAxisAlignment:CrossAxisAlignment.start,
mainAxisSize:MainAxisSize.min,children:[
Text(notif.title,style:const TextStyle(color:Colors.white,fontWeight:FontWeight.bold,fontSize:13)),
Text(notif.body,style:const TextStyle(color:Colors.white70,fontSize:11),
maxLines:2,overflow:TextOverflow.ellipsis),
])),
IconButton(icon:const Icon(Icons.close,color:Colors.white,size:18),onPressed:onDismiss),
]))));
}
}

View File

@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../core/app_colors.dart';
import '../services/auth_service.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _emailCtrl = TextEditingController();
final _passCtrl = TextEditingController();
bool _loading = false, _obscure = true;
Future<void> _login() async {
if (_emailCtrl.text.isEmpty || _passCtrl.text.isEmpty) {
_snack('Llena todos los campos', isError: true); return;
}
setState(() => _loading = true);
final err = await context.read<AuthService>().login(_emailCtrl.text, _passCtrl.text);
if (!mounted) return;
setState(() => _loading = false);
if (err != null) { _snack(err, isError: true); return; }
final rol = context.read<AuthService>().rol;
switch (rol) {
case 'ADMINISTRADOR': Navigator.pushReplacementNamed(context, '/admin'); break;
case 'CONDUCTOR': Navigator.pushReplacementNamed(context, '/driver'); break;
default: Navigator.pushReplacementNamed(context, '/home'); break;
}
}
void _snack(String msg, {bool isError = false}) => ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content:Text(msg),
backgroundColor: isError ? AppColors.rojoError : AppColors.verdeExito));
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: AppColors.grisFondo,
body: SingleChildScrollView(child: Column(children: [
Container(width:double.infinity, color:AppColors.guindaPrimary,
padding:const EdgeInsets.only(top:60,bottom:28),
child:Column(children:[
Container(width:84,height:84,
decoration:BoxDecoration(color:Colors.white12,shape:BoxShape.circle,
border:Border.all(color:AppColors.dorado,width:2.5)),
child:const Icon(Icons.delete_sweep_rounded,size:44,color:AppColors.dorado)),
const SizedBox(height:14),
const Text('H. AYUNTAMIENTO DE CELAYA',
style:TextStyle(color:Colors.white,fontSize:13,fontWeight:FontWeight.bold,letterSpacing:1.2)),
const SizedBox(height:4),
const Text('Sistema de Recolección de Residuos',
style:TextStyle(color:AppColors.dorado,fontSize:13)),
])),
Container(height:4,color:AppColors.dorado),
Padding(padding:const EdgeInsets.all(24), child:Card(elevation:4,
shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(12)),
child:Padding(padding:const EdgeInsets.all(24), child:Column(
crossAxisAlignment:CrossAxisAlignment.start, children:[
const Text('Iniciar Sesión',style:TextStyle(fontSize:20,
fontWeight:FontWeight.bold,color:AppColors.guindaPrimary)),
const SizedBox(height:16),
// Accesos rápidos demo
Container(padding:const EdgeInsets.all(10),
decoration:BoxDecoration(color:Colors.blue.shade50,borderRadius:BorderRadius.circular(8)),
child:const Column(crossAxisAlignment:CrossAxisAlignment.start, children:[
Text('Demo rápido:',style:TextStyle(fontWeight:FontWeight.bold,fontSize:12,color:AppColors.azulInfo)),
Text('Admin: admin@celaya.gob.mx / admin123',style:TextStyle(fontSize:11)),
Text('Conductor: conductor@celaya.gob.mx / conductor123',style:TextStyle(fontSize:11)),
])),
const SizedBox(height:16),
TextField(controller:_emailCtrl,keyboardType:TextInputType.emailAddress,
decoration:const InputDecoration(labelText:'Correo electrónico',
prefixIcon:Icon(Icons.email_outlined,color:AppColors.guindaPrimary),
border:OutlineInputBorder())),
const SizedBox(height:12),
TextField(controller:_passCtrl,obscureText:_obscure,
decoration:InputDecoration(labelText:'Contraseña',
prefixIcon:const Icon(Icons.lock_outline,color:AppColors.guindaPrimary),
border:const OutlineInputBorder(),
suffixIcon:IconButton(icon:Icon(_obscure?Icons.visibility_off:Icons.visibility),
onPressed:()=>setState(()=>_obscure=!_obscure)))),
const SizedBox(height:20),
SizedBox(width:double.infinity,height:50,
child:ElevatedButton(onPressed:_loading?null:_login,
style:ElevatedButton.styleFrom(backgroundColor:AppColors.guindaPrimary,
foregroundColor:Colors.white,shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(8))),
child:_loading?const CircularProgressIndicator(color:Colors.white,strokeWidth:2)
:const Text('ENTRAR',style:TextStyle(fontWeight:FontWeight.bold,letterSpacing:1)))),
const SizedBox(height:12),
const Divider(),
const SizedBox(height:12),
SizedBox(width:double.infinity,height:50,
child:OutlinedButton(onPressed:()=>Navigator.pushNamed(context,'/register'),
style:OutlinedButton.styleFrom(foregroundColor:AppColors.guindaPrimary,
side:const BorderSide(color:AppColors.guindaPrimary),
shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(8))),
child:const Text('CREAR CUENTA CIUDADANO',style:TextStyle(fontWeight:FontWeight.bold)))),
])))),
const Padding(padding:EdgeInsets.only(bottom:20),
child:Text('Gobierno Municipal de Celaya • Guanajuato',
style:TextStyle(color:AppColors.grisTexto,fontSize:11))),
])),
);
@override void dispose() { _emailCtrl.dispose(); _passCtrl.dispose(); super.dispose(); }
}

View File

@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../core/app_colors.dart';
import '../data/colonies_data.dart';
import '../models/route_model.dart';
import '../services/auth_service.dart';
class RegisterScreen extends StatefulWidget {
const RegisterScreen({super.key});
@override State<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends State<RegisterScreen> {
final _nombre = TextEditingController();
final _email = TextEditingController();
final _pass = TextEditingController();
final _calle = TextEditingController();
ColonyModel? _colony;
bool _loading = false, _obscure = true;
Future<void> _register() async {
if ([_nombre,_email,_pass,_calle].any((c)=>c.text.trim().isEmpty) || _colony==null) {
_snack('Completa todos los campos', isError:true); return; }
if (_pass.text.length < 6) { _snack('Contraseña mínimo 6 caracteres', isError:true); return; }
setState(()=>_loading=true);
final err = await context.read<AuthService>().register(
nombre:_nombre.text, email:_email.text, password:_pass.text,
calle:_calle.text, colonia:_colony!.colonia,
routeId:_colony!.routeId, horarioEstimado:_colony!.horarioEstimado);
if (!mounted) return;
setState(()=>_loading=false);
if (err!=null) { _snack(err,isError:true); return; }
Navigator.pushReplacementNamed(context, '/home');
}
void _snack(String msg,{bool isError=false}) => ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content:Text(msg),
backgroundColor:isError?AppColors.rojoError:AppColors.verdeExito));
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: AppColors.grisFondo,
appBar: AppBar(backgroundColor:AppColors.guindaPrimary,foregroundColor:Colors.white,
title:const Text('Registro Ciudadano'),
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
child:Container(height:4,color:AppColors.dorado))),
body: SingleChildScrollView(padding:const EdgeInsets.all(20), child:Column(children:[
_field(_nombre,'Nombre completo',Icons.badge_outlined),
const SizedBox(height:12),
_field(_email,'Correo electrónico',Icons.email_outlined,type:TextInputType.emailAddress),
const SizedBox(height:12),
TextField(controller:_pass,obscureText:_obscure,
decoration:InputDecoration(labelText:'Contraseña (mín. 6)',
prefixIcon:const Icon(Icons.lock_outline,color:AppColors.guindaPrimary),
border:const OutlineInputBorder(),filled:true,fillColor:Colors.white,
suffixIcon:IconButton(icon:Icon(_obscure?Icons.visibility_off:Icons.visibility),
onPressed:()=>setState(()=>_obscure=!_obscure)))),
const SizedBox(height:20),
const Align(alignment:Alignment.centerLeft,
child:Text('Domicilio',style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.guindaPrimary,fontSize:16))),
const SizedBox(height:10),
_field(_calle,'Calle y número',Icons.signpost_outlined),
const SizedBox(height:12),
DropdownButtonFormField<String>(
decoration:const InputDecoration(labelText:'Colonia',
prefixIcon:Icon(Icons.location_city,color:AppColors.guindaPrimary),
border:OutlineInputBorder(),filled:true,fillColor:Colors.white),
hint:const Text('Selecciona tu colonia'),
value:_colony?.colonia, isExpanded:true,
items:colonyNames.map((n)=>DropdownMenuItem(value:n,child:Text(n,style:const TextStyle(fontSize:13)))).toList(),
onChanged:(v){ if(v!=null) setState(()=>_colony=getColonyByName(v)); }),
if (_colony!=null) ...[
const SizedBox(height:10),
Container(padding:const EdgeInsets.all(12),
decoration:BoxDecoration(color:AppColors.guindaPrimary.withOpacity(0.08),
borderRadius:BorderRadius.circular(8),
border:Border.all(color:AppColors.guindaPrimary.withOpacity(0.3))),
child:Column(crossAxisAlignment:CrossAxisAlignment.start,children:[
Text('Ruta: ${_colony!.routeId}',style:const TextStyle(color:AppColors.guindaPrimary,fontWeight:FontWeight.bold)),
Text('Horario: ${_colony!.horarioEstimado}',style:const TextStyle(color:AppColors.grisTexto,fontSize:12)),
]))],
const SizedBox(height:24),
SizedBox(width:double.infinity,height:50,
child:ElevatedButton(onPressed:_loading?null:_register,
style:ElevatedButton.styleFrom(backgroundColor:AppColors.guindaPrimary,
foregroundColor:Colors.white,shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(8))),
child:_loading?const CircularProgressIndicator(color:Colors.white,strokeWidth:2)
:const Text('REGISTRARME',style:TextStyle(fontWeight:FontWeight.bold,letterSpacing:1)))),
const SizedBox(height:20),
])),
);
Widget _field(TextEditingController c, String label, IconData icon,
{TextInputType type=TextInputType.text}) =>
TextField(controller:c,keyboardType:type,
decoration:InputDecoration(labelText:label,
prefixIcon:Icon(icon,color:AppColors.guindaPrimary),
border:const OutlineInputBorder(),filled:true,fillColor:Colors.white));
@override void dispose(){ _nombre.dispose();_email.dispose();_pass.dispose();_calle.dispose();super.dispose(); }
}

View File

@@ -0,0 +1,53 @@
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';
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@override State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen> {
@override
void initState() { super.initState(); _go(); }
Future<void> _go() async {
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return;
final auth = context.read<AuthService>();
context.read<RouteSimulatorService>().startAllRoutes();
if (auth.isLoggedIn) {
_navigate(auth.rol);
} else {
Navigator.pushReplacementNamed(context, '/login');
}
}
void _navigate(String rol) {
switch (rol) {
case 'ADMINISTRADOR': Navigator.pushReplacementNamed(context, '/admin'); break;
case 'CONDUCTOR': Navigator.pushReplacementNamed(context, '/driver'); break;
default: Navigator.pushReplacementNamed(context, '/home'); break;
}
}
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: AppColors.guindaPrimary,
body: Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Container(width:100,height:100,
decoration:BoxDecoration(color:Colors.white12,shape:BoxShape.circle,
border:Border.all(color:AppColors.dorado,width:3)),
child:const Icon(Icons.delete_sweep_rounded,size:52,color:AppColors.dorado)),
const SizedBox(height:20),
const Text('CELAYA LIMPIA',style:TextStyle(color:Colors.white,fontSize:26,
fontWeight:FontWeight.bold,letterSpacing:2)),
const SizedBox(height:4),
const Text('H. Ayuntamiento de Celaya',style:TextStyle(color:Colors.white60,fontSize:13)),
const SizedBox(height:40),
const CircularProgressIndicator(valueColor:AlwaysStoppedAnimation<Color>(AppColors.dorado)),
])),
);
}