Primera app funcional
This commit is contained in:
786
lib/screens/admin/admin_dashboard_screen.dart
Normal file
786
lib/screens/admin/admin_dashboard_screen.dart
Normal 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),
|
||||
]))));
|
||||
}
|
||||
175
lib/screens/citizen/ai_camera_screen.dart
Normal file
175
lib/screens/citizen/ai_camera_screen.dart
Normal 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)),
|
||||
)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
149
lib/screens/citizen/citizen_guia_screen.dart
Normal file
149
lib/screens/citizen/citizen_guia_screen.dart
Normal 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))),
|
||||
],
|
||||
])),
|
||||
])));
|
||||
}
|
||||
}
|
||||
448
lib/screens/citizen/citizen_home_screen.dart
Normal file
448
lib/screens/citizen/citizen_home_screen.dart
Normal 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),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
117
lib/screens/citizen/citizen_reporte_screen.dart
Normal file
117
lib/screens/citizen/citizen_reporte_screen.dart
Normal 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(); }
|
||||
}
|
||||
456
lib/screens/driver/driver_home_screen.dart
Normal file
456
lib/screens/driver/driver_home_screen.dart
Normal 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),
|
||||
]))));
|
||||
}
|
||||
}
|
||||
106
lib/screens/login_screen.dart
Normal file
106
lib/screens/login_screen.dart
Normal 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(); }
|
||||
}
|
||||
101
lib/screens/register_screen.dart
Normal file
101
lib/screens/register_screen.dart
Normal 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(); }
|
||||
}
|
||||
53
lib/screens/splash_screen.dart
Normal file
53
lib/screens/splash_screen.dart
Normal 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)),
|
||||
])),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user