Avance del programa
This commit is contained in:
@@ -12,7 +12,8 @@ class DbHelper {
|
||||
|
||||
static Future<Database> _initDb() async {
|
||||
final path = join(await getDatabasesPath(), 'celaya_v3.db');
|
||||
return openDatabase(path, version: 1, onCreate: _onCreate);
|
||||
return openDatabase(path, version: 2,
|
||||
onCreate: _onCreate, onUpgrade: _onUpgrade);
|
||||
}
|
||||
|
||||
static Future<void> _onCreate(Database db, int v) async {
|
||||
@@ -76,6 +77,28 @@ class DbHelper {
|
||||
user_id INTEGER PRIMARY KEY, activo INTEGER DEFAULT 1,
|
||||
notas TEXT)''');
|
||||
|
||||
// NOTAS DE ADMIN SOBRE REPORTES
|
||||
await db.execute('''CREATE TABLE reporte_notas(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
reporte_id INTEGER NOT NULL, admin_id INTEGER NOT NULL,
|
||||
admin_nombre TEXT NOT NULL, nota TEXT NOT NULL,
|
||||
fecha TEXT NOT NULL)''');
|
||||
|
||||
// EVIDENCIAS DEL ADMIN EN REPORTES
|
||||
await db.execute('''CREATE TABLE reporte_evidencias(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
reporte_id INTEGER NOT NULL, admin_id INTEGER NOT NULL,
|
||||
pie_imagen TEXT NOT NULL, foto_path TEXT,
|
||||
fecha TEXT NOT NULL)''');
|
||||
|
||||
// CHAT POR REPORTE (admin <-> ciudadano)
|
||||
await db.execute('''CREATE TABLE reporte_chat(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
reporte_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
|
||||
rol TEXT NOT NULL,
|
||||
mensaje TEXT NOT NULL, fecha TEXT NOT NULL,
|
||||
leido INTEGER DEFAULT 0)''');
|
||||
|
||||
await db.insert('users', {'nombre':'Administrador','email':'admin@celaya.gob.mx',
|
||||
'password':'admin123','rol':'ADMINISTRADOR'});
|
||||
final conductorId = await db.insert('users', {'nombre':'Juan Conductor',
|
||||
@@ -83,6 +106,47 @@ class DbHelper {
|
||||
await db.insert('user_meta', {'user_id': conductorId, 'activo': 1});
|
||||
}
|
||||
|
||||
// Migración incremental — se ejecuta al actualizar la app
|
||||
static Future<void> _onUpgrade(Database db, int oldV, int newV) async {
|
||||
// Agregar columnas/tablas que pueden faltar en instalaciones anteriores
|
||||
final helpers = [
|
||||
// foto_path en reportes
|
||||
"ALTER TABLE reportes ADD COLUMN foto_path TEXT",
|
||||
// Tablas nuevas (IF NOT EXISTS para no fallar si ya existen)
|
||||
'''CREATE TABLE IF NOT EXISTS notification_history(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER, route_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL, title TEXT NOT NULL,
|
||||
body TEXT NOT NULL, fecha TEXT NOT NULL,
|
||||
leida INTEGER DEFAULT 0)''',
|
||||
'''CREATE TABLE IF NOT EXISTS user_meta(
|
||||
user_id INTEGER PRIMARY KEY, activo INTEGER DEFAULT 1, notas TEXT)''',
|
||||
'''CREATE TABLE IF NOT EXISTS reporte_notas(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
reporte_id INTEGER NOT NULL, admin_id INTEGER NOT NULL,
|
||||
admin_nombre TEXT NOT NULL, nota TEXT NOT NULL, fecha TEXT NOT NULL)''',
|
||||
'''CREATE TABLE IF NOT EXISTS reporte_evidencias(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
reporte_id INTEGER NOT NULL, admin_id INTEGER NOT NULL,
|
||||
pie_imagen TEXT NOT NULL, foto_path TEXT, fecha TEXT NOT NULL)''',
|
||||
'''CREATE TABLE IF NOT EXISTS reporte_chat(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
reporte_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
|
||||
rol TEXT NOT NULL, mensaje TEXT NOT NULL,
|
||||
fecha TEXT NOT NULL, leido INTEGER DEFAULT 0)''',
|
||||
'''CREATE TABLE IF NOT EXISTS route_definitions(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
route_id TEXT UNIQUE NOT NULL, nombre TEXT NOT NULL,
|
||||
dias TEXT NOT NULL, hora_inicio TEXT NOT NULL,
|
||||
hora_fin TEXT NOT NULL, turno TEXT NOT NULL,
|
||||
colonias TEXT NOT NULL, activa INTEGER DEFAULT 1)''',
|
||||
];
|
||||
for (final sql in helpers) {
|
||||
try { await db.execute(sql); } catch (_) {}
|
||||
// Ignorar errores (ej. columna ya existe)
|
||||
}
|
||||
}
|
||||
|
||||
// ── USERS ────────────────────────────────────────────────────────────────
|
||||
static Future<int> insertUser(UserModel u) async =>
|
||||
(await database).insert('users', u.toMap(), conflictAlgorithm: ConflictAlgorithm.abort);
|
||||
@@ -385,4 +449,50 @@ class DbHelper {
|
||||
FROM reviews
|
||||
GROUP BY semana ORDER BY semana DESC LIMIT 8''');
|
||||
}
|
||||
|
||||
// ── NOTAS DE ADMIN ───────────────────────────────────────────────────────
|
||||
static Future<void> insertReporteNota(int reporteId, int adminId, String adminNombre, String nota) async =>
|
||||
(await database).insert('reporte_notas', {
|
||||
'reporte_id': reporteId, 'admin_id': adminId,
|
||||
'admin_nombre': adminNombre, 'nota': nota,
|
||||
'fecha': DateTime.now().toIso8601String(),
|
||||
});
|
||||
|
||||
static Future<List<Map<String, dynamic>>> getReporteNotas(int reporteId) async =>
|
||||
(await database).query('reporte_notas',
|
||||
where: 'reporte_id=?', whereArgs: [reporteId], orderBy: 'fecha ASC');
|
||||
|
||||
// ── EVIDENCIAS DEL ADMIN ─────────────────────────────────────────────────
|
||||
static Future<void> insertReporteEvidencia(int reporteId, int adminId, String pie, String? fotoPath) async =>
|
||||
(await database).insert('reporte_evidencias', {
|
||||
'reporte_id': reporteId, 'admin_id': adminId,
|
||||
'pie_imagen': pie, 'foto_path': fotoPath,
|
||||
'fecha': DateTime.now().toIso8601String(),
|
||||
});
|
||||
|
||||
static Future<List<Map<String, dynamic>>> getReporteEvidencias(int reporteId) async =>
|
||||
(await database).query('reporte_evidencias',
|
||||
where: 'reporte_id=?', whereArgs: [reporteId], orderBy: 'fecha ASC');
|
||||
|
||||
// ── CHAT POR REPORTE ─────────────────────────────────────────────────────
|
||||
static Future<void> insertChatMsg(int reporteId, int userId, String rol, String mensaje) async =>
|
||||
(await database).insert('reporte_chat', {
|
||||
'reporte_id': reporteId, 'user_id': userId, 'rol': rol,
|
||||
'mensaje': mensaje, 'fecha': DateTime.now().toIso8601String(), 'leido': 0,
|
||||
});
|
||||
|
||||
static Future<List<Map<String, dynamic>>> getChatMsgs(int reporteId) async =>
|
||||
(await database).query('reporte_chat',
|
||||
where: 'reporte_id=?', whereArgs: [reporteId], orderBy: 'fecha ASC');
|
||||
|
||||
static Future<int> getChatUnread(int reporteId, String rolLector) async {
|
||||
final res = await (await database).rawQuery(
|
||||
"SELECT COUNT(*) as c FROM reporte_chat WHERE reporte_id=? AND rol!=? AND leido=0",
|
||||
[reporteId, rolLector]);
|
||||
return (res.first['c'] as int? ?? 0);
|
||||
}
|
||||
|
||||
static Future<void> markChatRead(int reporteId, String rolLector) async =>
|
||||
(await database).update('reporte_chat', {'leido': 1},
|
||||
where: 'reporte_id=? AND rol!=?', whereArgs: [reporteId, rolLector]);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../../models/models.dart';
|
||||
import '../../data/routes_data.dart';
|
||||
import '../../models/route_model.dart' show ColonyModel;
|
||||
import 'create_route_screen.dart';
|
||||
import 'admin_reporte_detalle_screen.dart';
|
||||
import 'admin_stats_screen.dart';
|
||||
import 'manage_conductors_screen.dart';
|
||||
import 'export_pdf_screen.dart';
|
||||
@@ -29,14 +30,13 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
||||
final last = sim.lastNotification;
|
||||
|
||||
final tabs = [
|
||||
_AdminHomeTab(sim:sim, auth:auth),
|
||||
_AdminMapTab(sim:sim),
|
||||
_AdminReportesTab(),
|
||||
_AdminConductoresTab(),
|
||||
_AdminAssignmentsTab(),
|
||||
_AdminAlertasTab(sim:sim),
|
||||
_AdminRoutesTab(),
|
||||
_AdminReviewsTab(),
|
||||
_AdminHomeTab(sim:sim, auth:auth), // 0 Panel
|
||||
_AdminMapTab(sim:sim), // 1 Mapa
|
||||
_AdminReportesTab(), // 2 Reportes
|
||||
_AdminAssignmentsTab(), // 3 Asignar
|
||||
_AdminAlertasTab(sim:sim), // 4 Alertas
|
||||
_AdminRoutesTab(), // 5 Rutas
|
||||
_AdminReviewsTab(), // 6 Reseñas
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
@@ -57,8 +57,8 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
||||
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.people_alt_outlined),
|
||||
selectedIcon:Icon(Icons.people_alt,color:AppColors.verdeAdmin),label:'Asignar'),
|
||||
NavigationDestination(icon:Icon(Icons.warning_outlined),
|
||||
selectedIcon:Icon(Icons.warning,color:AppColors.verdeAdmin),label:'Alertas'),
|
||||
NavigationDestination(icon:Icon(Icons.route_outlined),
|
||||
@@ -404,419 +404,131 @@ class _AdminMapTab extends StatelessWidget {
|
||||
}
|
||||
|
||||
// ── TAB 3: Reportes ciudadanos ────────────────────────────────────────────
|
||||
// ── TAB 2: Reportes ciudadanos ───────────────────────────────────────────
|
||||
class _AdminReportesTab extends StatefulWidget {
|
||||
@override State<_AdminReportesTab> createState() => _AdminReportesTabState();
|
||||
}
|
||||
|
||||
class _AdminReportesTabState extends State<_AdminReportesTab> {
|
||||
List<Map<String,dynamic>> _reportes = [];
|
||||
List<Map<String, dynamic>> _reportes = [];
|
||||
bool _loading = true;
|
||||
String _filtroEstado = 'TODOS';
|
||||
|
||||
static const _estados = ['TODOS','PENDIENTE','EN_REVISION','EN_PROCESO','RESUELTO','COMPLETADO'];
|
||||
|
||||
@override void initState() { super.initState(); _load(); }
|
||||
|
||||
Future<void> _load() async {
|
||||
final r = await DbHelper.getReportesConUsuario();
|
||||
if (mounted) setState(() { _reportes=r; _loading=false; });
|
||||
if (mounted) setState(() { _reportes = r; _loading = false; });
|
||||
}
|
||||
|
||||
static const _tipos = {
|
||||
'CAMION_NO_PASO':'🚛 No pasó','RETRASO':'⏱️ Retraso',
|
||||
'RESIDUOS_NO_RECOGIDOS':'🗑️ No recogidos','OTRO':'📝 Otro',
|
||||
};
|
||||
List<Map<String,dynamic>> get _filtered => _filtroEstado == 'TODOS'
|
||||
? _reportes
|
||||
: _reportes.where((r) => (r['estado'] as String?) == _filtroEstado).toList();
|
||||
|
||||
@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?;
|
||||
final fotoPath = r['foto_path'] as String?;
|
||||
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)),
|
||||
if (fotoPath != null && fotoPath.isNotEmpty) ...[
|
||||
const SizedBox(height:6),
|
||||
ClipRRect(borderRadius:BorderRadius.circular(6),
|
||||
child:Image.file(File(fotoPath), height:100, width:double.infinity,
|
||||
fit:BoxFit.cover)),
|
||||
],
|
||||
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;}
|
||||
Color _estadoColor(String s) {
|
||||
switch(s) {
|
||||
case 'COMPLETADO': return AppColors.verdeExito;
|
||||
case 'RESUELTO': return Colors.teal;
|
||||
case 'EN_PROCESO': return AppColors.azulInfo;
|
||||
case 'EN_REVISION': return AppColors.naranjaAlerta;
|
||||
default: return AppColors.grisTexto;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
String _estadoLabel(String s) => s.replaceAll('_', ' ');
|
||||
|
||||
// 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!);
|
||||
}
|
||||
String _folio(int id) => 'RPT-${id.toString().padLeft(5, "0")}';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(automaticallyImplyLeading: false,
|
||||
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
|
||||
title: const Text('Asignar Rutas a Conductores'),
|
||||
title: Text('Reportes Ciudadanos (${_filtered.length})'),
|
||||
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(
|
||||
child: Container(height: 4, color: AppColors.dorado)),
|
||||
actions: [IconButton(icon: const Icon(Icons.refresh), onPressed: _load)]),
|
||||
body: Column(children: [
|
||||
Container(color: Colors.white, height: 44,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
itemCount: _estados.length,
|
||||
itemBuilder: (_, i) {
|
||||
final e = _estados[i];
|
||||
final sel = _filtroEstado == e;
|
||||
return Padding(padding: const EdgeInsets.only(right: 6),
|
||||
child: FilterChip(
|
||||
label: Text(e == 'TODOS' ? 'Todos' : _estadoLabel(e),
|
||||
style: TextStyle(fontSize: 11,
|
||||
color: sel ? Colors.white : AppColors.negroTexto)),
|
||||
selected: sel,
|
||||
selectedColor: e == 'TODOS' ? AppColors.verdeAdmin : _estadoColor(e),
|
||||
checkmarkColor: Colors.white,
|
||||
onSelected: (_) => setState(() => _filtroEstado = e)));
|
||||
})),
|
||||
Expanded(child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _filtered.isEmpty
|
||||
? const Center(child: Text('Sin reportes',
|
||||
style: TextStyle(color: AppColors.grisTexto)))
|
||||
: RefreshIndicator(onRefresh: _load,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(10),
|
||||
itemCount: _filtered.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final r = _filtered[i];
|
||||
final estado = r['estado'] as String? ?? 'PENDIENTE';
|
||||
final id = r['id'] as int? ?? 0;
|
||||
final folio = _folio(id);
|
||||
final fotoPath = r['foto_path'] as String?;
|
||||
final nombre = r['user_nombre'] as String? ?? 'Ciudadano';
|
||||
final colonia = r['colonia'] as String? ?? '';
|
||||
final desc = r['descripcion'] as String? ?? '';
|
||||
return Card(margin: const EdgeInsets.only(bottom: 8),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10),
|
||||
side: BorderSide(color: widget.color.withOpacity(0.3))),
|
||||
child: Padding(padding: const EdgeInsets.all(14), child: Column(
|
||||
side: BorderSide(color: _estadoColor(estado).withOpacity(0.3))),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: () async {
|
||||
await Navigator.push(ctx, MaterialPageRoute(
|
||||
builder: (_) => AdminReporteDetalleScreen(reporte: r)));
|
||||
_load();
|
||||
},
|
||||
child: Padding(padding: const EdgeInsets.all(12), child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Icon(widget.icon, color: widget.color, size: 18),
|
||||
Text(folio, style: const TextStyle(fontWeight: FontWeight.bold,
|
||||
fontSize: 13, color: AppColors.verdeAdmin)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(widget.label,
|
||||
style: TextStyle(fontWeight: FontWeight.bold, color: widget.color, fontSize: 13))),
|
||||
Container(padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: _estadoColor(estado).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: _estadoColor(estado).withOpacity(0.4))),
|
||||
child: Text(_estadoLabel(estado),
|
||||
style: TextStyle(fontSize: 9, fontWeight: FontWeight.bold,
|
||||
color: _estadoColor(estado)))),
|
||||
const Spacer(),
|
||||
if (fotoPath != null && fotoPath.isNotEmpty)
|
||||
const Icon(Icons.photo_camera, size: 14, color: AppColors.azulInfo),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.chevron_right, color: AppColors.grisTexto, size: 18),
|
||||
]),
|
||||
const SizedBox(height: 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)),
|
||||
const SizedBox(height: 4),
|
||||
Text('$nombre — $colonia',
|
||||
style: const TextStyle(color: AppColors.grisTexto, fontSize: 11)),
|
||||
const SizedBox(height: 2),
|
||||
Text(desc, style: const TextStyle(fontSize: 12),
|
||||
maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
]))));
|
||||
}))),
|
||||
]),
|
||||
])));
|
||||
}
|
||||
|
||||
class _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),
|
||||
]))));
|
||||
}
|
||||
|
||||
|
||||
// ── TAB Conductores (delega a ManageConductorsScreen) ────────────────────
|
||||
class _AdminConductoresTab extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
@@ -1068,3 +780,273 @@ class _AdminReviewsTabState extends State<_AdminReviewsTab> {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── TAB 3: Asignaciones LMV / MJS ────────────────────────────────────────
|
||||
class _AdminAssignmentsTab extends StatefulWidget {
|
||||
@override State<_AdminAssignmentsTab> createState() => _AdminAssignmentsTabState();
|
||||
}
|
||||
|
||||
class _AdminAssignmentsTabState extends State<_AdminAssignmentsTab> {
|
||||
List<UserModel> _conductores = [];
|
||||
UserModel? _sel;
|
||||
List<AssignmentModel> _asigs = [];
|
||||
|
||||
static const _grupoA = ['LUNES', 'MIERCOLES', 'VIERNES'];
|
||||
static const _grupoB = ['MARTES', 'JUEVES', 'SABADO'];
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
AssignmentModel? _getGrupo(List<String> dias) {
|
||||
for (final dia in dias) {
|
||||
try { return _asigs.firstWhere((a) => a.diaSemana == dia); } catch (_) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _saveGrupo(List<String> dias, String routeId, String turno) async {
|
||||
for (final dia in dias) {
|
||||
await DbHelper.upsertAsignacion(AssignmentModel(
|
||||
conductorId: _sel!.id!, routeId: routeId, diaSemana: dia, turno: turno));
|
||||
}
|
||||
await _loadAsigs(_sel!.id!);
|
||||
}
|
||||
|
||||
@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: [
|
||||
Container(padding: const EdgeInsets.all(10), margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(color: Colors.blue.shade50, borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.blue.shade200)),
|
||||
child: const Text(
|
||||
'Cada conductor opera en un bloque:\n'
|
||||
'Grupo A — Lunes, Miercoles y Viernes\n'
|
||||
'Grupo B — Martes, Jueves y Sabado',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.azulInfo))),
|
||||
DropdownButtonFormField<UserModel>(
|
||||
decoration: const InputDecoration(labelText: 'Selecciona conductor',
|
||||
border: OutlineInputBorder(), filled: true, fillColor: Colors.white),
|
||||
hint: const Text('Conductor...'),
|
||||
value: _sel,
|
||||
items: _conductores.map((c) => DropdownMenuItem(value: c,
|
||||
child: Text(c.nombre, style: const TextStyle(fontSize: 13)))).toList(),
|
||||
onChanged: (c) { setState(() => _sel = c); if (c != null) _loadAsigs(c.id!); }),
|
||||
if (_sel != null) ...[
|
||||
const SizedBox(height: 20),
|
||||
_GrupoRow(label: 'Grupo A — Lunes, Miercoles y Viernes',
|
||||
icon: Icons.wb_sunny_outlined, color: Colors.blue,
|
||||
current: _getGrupo(_grupoA), routeIds: routesData.map((r) => r.routeId).toList(),
|
||||
onSave: (rid, turno) => _saveGrupo(_grupoA, rid, turno)),
|
||||
const SizedBox(height: 12),
|
||||
_GrupoRow(label: 'Grupo B — Martes, Jueves y Sabado',
|
||||
icon: Icons.wb_twilight, color: Colors.deepPurple,
|
||||
current: _getGrupo(_grupoB), routeIds: routesData.map((r) => r.routeId).toList(),
|
||||
onSave: (rid, turno) => _saveGrupo(_grupoB, rid, turno)),
|
||||
if (_asigs.isNotEmpty) ...[
|
||||
const SizedBox(height: 20),
|
||||
const Text('Resumen actual', style: TextStyle(fontWeight: FontWeight.bold,
|
||||
color: AppColors.verdeAdmin, fontSize: 14)),
|
||||
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))),
|
||||
]))),
|
||||
]))),
|
||||
],
|
||||
],
|
||||
])),
|
||||
);
|
||||
}
|
||||
|
||||
class _GrupoRow extends StatefulWidget {
|
||||
final String label; final IconData icon; final Color color;
|
||||
final AssignmentModel? current; final List<String> routeIds;
|
||||
final Function(String, String) onSave;
|
||||
const _GrupoRow({required this.label, required this.icon, required this.color,
|
||||
required this.current, required this.routeIds, required this.onSave});
|
||||
@override State<_GrupoRow> createState() => _GrupoRowState();
|
||||
}
|
||||
|
||||
class _GrupoRowState extends State<_GrupoRow> {
|
||||
String? _route;
|
||||
String _turno = 'MATUTINO';
|
||||
@override void initState() { super.initState();
|
||||
_route = widget.current?.routeId; _turno = widget.current?.turno ?? 'MATUTINO'; }
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Card(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10),
|
||||
side: BorderSide(color: widget.color.withOpacity(0.3))),
|
||||
child: Padding(padding: const EdgeInsets.all(14), child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Icon(widget.icon, color: widget.color, size: 18), const SizedBox(width: 8),
|
||||
Expanded(child: Text(widget.label, style: TextStyle(
|
||||
fontWeight: FontWeight.bold, color: widget.color, fontSize: 13))),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
Row(children: [
|
||||
Expanded(child: DropdownButtonFormField<String>(
|
||||
value: _route,
|
||||
decoration: const InputDecoration(labelText: 'Ruta', border: OutlineInputBorder(),
|
||||
isDense: true, contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 10)),
|
||||
hint: const Text('Sin ruta', style: TextStyle(fontSize: 12)),
|
||||
items: widget.routeIds.map((r) => DropdownMenuItem(value: r,
|
||||
child: Text(r, style: const TextStyle(fontSize: 12)))).toList(),
|
||||
onChanged: (v) => setState(() => _route = v))),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(width: 130, child: DropdownButtonFormField<String>(
|
||||
value: _turno,
|
||||
decoration: const InputDecoration(labelText: 'Turno', border: OutlineInputBorder(),
|
||||
isDense: true, contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 10)),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'MATUTINO', child: Text('Matutino', style: TextStyle(fontSize: 12))),
|
||||
DropdownMenuItem(value: 'VESPERTINO', child: Text('Vespertino', style: TextStyle(fontSize: 12))),
|
||||
DropdownMenuItem(value: 'NOCTURNO', child: Text('Nocturno', style: TextStyle(fontSize: 12))),
|
||||
],
|
||||
onChanged: (v) => setState(() => _turno = v!))),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: _route == null ? null : () => widget.onSave(_route!, _turno),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: widget.color,
|
||||
foregroundColor: Colors.white, minimumSize: const Size(50, 42),
|
||||
padding: EdgeInsets.zero, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||
child: const Icon(Icons.save, size: 18)),
|
||||
]),
|
||||
])));
|
||||
}
|
||||
|
||||
// ── TAB 4: Alertas del sistema ────────────────────────────────────────────
|
||||
class _AdminAlertasTab extends StatefulWidget {
|
||||
final RouteSimulatorService sim;
|
||||
const _AdminAlertasTab({required this.sim});
|
||||
@override State<_AdminAlertasTab> createState() => _AdminAlertasTabState();
|
||||
}
|
||||
|
||||
class _AdminAlertasTabState extends State<_AdminAlertasTab> {
|
||||
List<AlertaModel> _alertas = [];
|
||||
bool _loading = true;
|
||||
|
||||
@override void initState() { super.initState(); _load(); }
|
||||
|
||||
Future<void> _load() async {
|
||||
final a = await DbHelper.getAlertas();
|
||||
if (mounted) setState(() { _alertas = a; _loading = false; });
|
||||
}
|
||||
|
||||
Color _alertaColor(String tipo) {
|
||||
if (tipo.startsWith('INCIDENTE')) return AppColors.naranjaAlerta;
|
||||
if (tipo == 'GPS_PERDIDO') return AppColors.rojoError;
|
||||
if (tipo == 'CAMION_DETENIDO') return AppColors.naranjaAlerta;
|
||||
if (tipo.startsWith('RUTA_')) return AppColors.rojoError;
|
||||
return AppColors.azulInfo;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(automaticallyImplyLeading: false,
|
||||
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
|
||||
title: Text('Alertas del Sistema (${_alertas.length})'),
|
||||
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
||||
child: Container(height: 4, color: AppColors.dorado)),
|
||||
actions: [IconButton(icon: const Icon(Icons.refresh), onPressed: _load)]),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _alertas.isEmpty
|
||||
? const Center(child: Text('Sin alertas',
|
||||
style: TextStyle(color: AppColors.grisTexto)))
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: _alertas.length,
|
||||
itemBuilder: (_, i) {
|
||||
final a = _alertas[i];
|
||||
final c = _alertaColor(a.tipo);
|
||||
return Card(margin: const EdgeInsets.only(bottom: 6),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8),
|
||||
side: BorderSide(color: c.withOpacity(0.3))),
|
||||
child: ListTile(dense: true,
|
||||
leading: CircleAvatar(radius: 18, backgroundColor: c.withOpacity(0.12),
|
||||
child: Icon(a.resuelta ? Icons.check : Icons.warning,
|
||||
color: a.resuelta ? AppColors.verdeExito : c, size: 16)),
|
||||
title: Text(a.tipo.replaceAll('_', ' '),
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold,
|
||||
color: a.resuelta ? AppColors.grisTexto : c)),
|
||||
subtitle: Text(a.mensaje, maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 11)),
|
||||
trailing: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Text(a.routeId, style: const TextStyle(
|
||||
fontSize: 10, color: AppColors.grisTexto)),
|
||||
if (!a.resuelta) TextButton(
|
||||
onPressed: () async {
|
||||
await DbHelper.resolverAlerta(a.id!); _load();
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
padding: EdgeInsets.zero, minimumSize: Size.zero,
|
||||
foregroundColor: AppColors.verdeExito),
|
||||
child: const Text('Resolver', style: TextStyle(fontSize: 10))),
|
||||
])));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Widgets auxiliares ────────────────────────────────────────────────────
|
||||
class _Stat extends StatelessWidget {
|
||||
final String label, value; final IconData icon; final Color color;
|
||||
const _Stat(this.label, this.value, this.icon, this.color);
|
||||
@override
|
||||
Widget build(BuildContext context) => Expanded(child: Card(child: Padding(
|
||||
padding: const EdgeInsets.all(14), child: Row(children: [
|
||||
CircleAvatar(radius: 20, backgroundColor: color.withOpacity(0.12),
|
||||
child: Icon(icon, color: color, size: 20)),
|
||||
const SizedBox(width: 10),
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(value, style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: color)),
|
||||
Text(label, style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
|
||||
]),
|
||||
]))));
|
||||
}
|
||||
|
||||
class _AdminBanner extends StatelessWidget {
|
||||
final AppNotification notif; final VoidCallback onDismiss;
|
||||
const _AdminBanner({required this.notif, required this.onDismiss});
|
||||
@override
|
||||
Widget build(BuildContext context) => Material(color: Colors.transparent,
|
||||
child: Container(margin: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(color: AppColors.verdeAdmin, borderRadius: BorderRadius.circular(10),
|
||||
boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 6)]),
|
||||
child: Padding(padding: const EdgeInsets.all(10), child: Row(children: [
|
||||
const Icon(Icons.notifications, color: Colors.white, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min, children: [
|
||||
Text(notif.title, style: const TextStyle(color: Colors.white,
|
||||
fontWeight: FontWeight.bold, fontSize: 12)),
|
||||
Text(notif.body, style: const TextStyle(color: Colors.white70, fontSize: 10),
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
])),
|
||||
IconButton(icon: const Icon(Icons.close, color: Colors.white, size: 16),
|
||||
onPressed: onDismiss),
|
||||
]))));
|
||||
}
|
||||
|
||||
356
lib/screens/admin/admin_reporte_detalle_screen.dart
Normal file
356
lib/screens/admin/admin_reporte_detalle_screen.dart
Normal file
@@ -0,0 +1,356 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../database/db_helper.dart';
|
||||
import '../../services/auth_service.dart';
|
||||
import '../shared/reporte_chat_screen.dart';
|
||||
|
||||
class AdminReporteDetalleScreen extends StatefulWidget {
|
||||
final Map<String, dynamic> reporte;
|
||||
const AdminReporteDetalleScreen({super.key, required this.reporte});
|
||||
@override State<AdminReporteDetalleScreen> createState() => _AdminReporteDetalleScreenState();
|
||||
}
|
||||
|
||||
class _AdminReporteDetalleScreenState extends State<AdminReporteDetalleScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabs;
|
||||
late Map<String, dynamic> _r;
|
||||
List<Map<String, dynamic>> _notas = [];
|
||||
List<Map<String, dynamic>> _evidencias = [];
|
||||
int _chatUnread = 0;
|
||||
final _notaCtrl = TextEditingController();
|
||||
final _pieCtrl = TextEditingController();
|
||||
File? _evidFoto;
|
||||
bool _loadingNota = false, _loadingEv = false;
|
||||
final _picker = ImagePicker();
|
||||
|
||||
static const _estados = [
|
||||
'PENDIENTE', 'EN_REVISION', 'EN_PROCESO', 'RESUELTO', 'COMPLETADO'
|
||||
];
|
||||
static const _estadoLabels = {
|
||||
'PENDIENTE': 'Pendiente',
|
||||
'EN_REVISION': 'En Revision',
|
||||
'EN_PROCESO': 'En Proceso',
|
||||
'RESUELTO': 'Resuelto',
|
||||
'COMPLETADO': 'Completado',
|
||||
};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_r = Map.from(widget.reporte);
|
||||
_tabs = TabController(length: 3, vsync: this);
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final id = _r['id'] as int;
|
||||
final n = await DbHelper.getReporteNotas(id);
|
||||
final e = await DbHelper.getReporteEvidencias(id);
|
||||
final u = await DbHelper.getChatUnread(id, 'ADMINISTRADOR');
|
||||
if (mounted) setState(() { _notas = n; _evidencias = e; _chatUnread = u; });
|
||||
}
|
||||
|
||||
Color _estadoColor(String s) {
|
||||
switch (s) {
|
||||
case 'COMPLETADO': return AppColors.verdeExito;
|
||||
case 'RESUELTO': return Colors.teal;
|
||||
case 'EN_PROCESO': return AppColors.azulInfo;
|
||||
case 'EN_REVISION': return AppColors.naranjaAlerta;
|
||||
default: return AppColors.grisTexto;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cambiarEstado(String nuevoEstado) async {
|
||||
await DbHelper.updateReporteEstado(_r['id'] as int, nuevoEstado);
|
||||
setState(() => _r['estado'] = nuevoEstado);
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('Estado actualizado a: ${_estadoLabels[nuevoEstado]}'),
|
||||
backgroundColor: _estadoColor(nuevoEstado)));
|
||||
}
|
||||
|
||||
Future<void> _agregarNota() async {
|
||||
if (_notaCtrl.text.trim().isEmpty) return;
|
||||
final auth = context.read<AuthService>();
|
||||
setState(() => _loadingNota = true);
|
||||
await DbHelper.insertReporteNota(
|
||||
_r['id'] as int, auth.currentUser!.id!,
|
||||
auth.currentUser!.nombre, _notaCtrl.text.trim());
|
||||
_notaCtrl.clear();
|
||||
await _load();
|
||||
setState(() => _loadingNota = false);
|
||||
}
|
||||
|
||||
Future<void> _agregarEvidencia() async {
|
||||
if (_pieCtrl.text.trim().isEmpty && _evidFoto == null) return;
|
||||
final auth = context.read<AuthService>();
|
||||
setState(() => _loadingEv = true);
|
||||
await DbHelper.insertReporteEvidencia(
|
||||
_r['id'] as int, auth.currentUser!.id!,
|
||||
_pieCtrl.text.trim(), _evidFoto?.path);
|
||||
_pieCtrl.clear();
|
||||
setState(() => _evidFoto = null);
|
||||
await _load();
|
||||
setState(() => _loadingEv = false);
|
||||
}
|
||||
|
||||
Future<void> _pickFoto() async {
|
||||
final p = await _picker.pickImage(source: ImageSource.camera, imageQuality: 70, maxWidth: 1024);
|
||||
if (p != null && mounted) setState(() => _evidFoto = File(p.path));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final estado = _r['estado'] as String? ?? 'PENDIENTE';
|
||||
final isClosed = estado == 'COMPLETADO';
|
||||
final folio = 'RPT-${(_r['id'] as int).toString().padLeft(5,'0')}';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.grisFondo,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
|
||||
title: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('Reporte $folio', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
|
||||
Text(_r['colonia'] as String? ?? '', style: const TextStyle(fontSize: 11, color: Colors.white70)),
|
||||
]),
|
||||
bottom: TabBar(controller: _tabs, indicatorColor: AppColors.dorado,
|
||||
labelColor: AppColors.dorado, unselectedLabelColor: Colors.white70,
|
||||
labelStyle: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
tabs: [
|
||||
const Tab(icon: Icon(Icons.info_outline, size: 18), text: 'Detalle'),
|
||||
const Tab(icon: Icon(Icons.sticky_note_2_outlined, size: 18), text: 'Notas'),
|
||||
Tab(icon: Stack(clipBehavior: Clip.none, children: [
|
||||
const Icon(Icons.chat_outlined, size: 18),
|
||||
if (_chatUnread > 0) Positioned(right: -4, top: -4,
|
||||
child: Container(width: 10, height: 10,
|
||||
decoration: const BoxDecoration(color: Colors.red, shape: BoxShape.circle))),
|
||||
]), text: 'Chat'),
|
||||
]),
|
||||
),
|
||||
body: TabBarView(controller: _tabs, children: [
|
||||
// ── TAB 0: Detalle + cambio estado + evidencias ──────────────────
|
||||
_buildDetalleTab(estado, isClosed, folio),
|
||||
// ── TAB 1: Notas internas del admin ──────────────────────────────
|
||||
_buildNotasTab(isClosed),
|
||||
// ── TAB 2: Chat con ciudadano ─────────────────────────────────────
|
||||
ReporteChatScreen(reporteId: _r['id'] as int,
|
||||
folio: folio, isClosed: isClosed),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetalleTab(String estado, bool isClosed, String folio) =>
|
||||
SingleChildScrollView(padding: const EdgeInsets.all(14), child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
// Info básica
|
||||
Card(child: Padding(padding: const EdgeInsets.all(14), child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('Reportado por: ${_r['user_nombre'] ?? 'Ciudadano'}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
|
||||
Text(_r['user_email'] ?? '',
|
||||
style: const TextStyle(color: AppColors.grisTexto, fontSize: 11)),
|
||||
])),
|
||||
_EstadoBadge(estado: estado, color: _estadoColor(estado)),
|
||||
]),
|
||||
const Divider(),
|
||||
Text('Tipo: ${_r['tipo'] ?? ''}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
Text('Colonia: ${_r['colonia'] ?? ''} — Ruta: ${_r['route_id'] ?? ''}',
|
||||
style: const TextStyle(color: AppColors.grisTexto, fontSize: 12)),
|
||||
const SizedBox(height: 6),
|
||||
Text(_r['descripcion'] ?? '',
|
||||
style: const TextStyle(fontSize: 13, height: 1.4)),
|
||||
if (_r['foto_path'] != null && (_r['foto_path'] as String).isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
const Text('Foto del ciudadano:',
|
||||
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
const SizedBox(height: 4),
|
||||
ClipRRect(borderRadius: BorderRadius.circular(8),
|
||||
child: Image.file(File(_r['foto_path'] as String),
|
||||
height: 140, width: double.infinity, fit: BoxFit.cover)),
|
||||
],
|
||||
]))),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Cambiar estado
|
||||
if (!isClosed) ...[
|
||||
const Text('Cambiar Estado', style: TextStyle(fontWeight: FontWeight.bold,
|
||||
color: AppColors.verdeAdmin, fontSize: 14)),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(spacing: 8, runSpacing: 8, children: _estados.map((e) {
|
||||
final isActual = e == estado;
|
||||
return ActionChip(
|
||||
label: Text(_estadoLabels[e]!, style: TextStyle(
|
||||
fontWeight: isActual ? FontWeight.bold : FontWeight.normal,
|
||||
fontSize: 12, color: isActual ? Colors.white : _estadoColor(e))),
|
||||
backgroundColor: isActual ? _estadoColor(e) : _estadoColor(e).withOpacity(0.1),
|
||||
side: BorderSide(color: _estadoColor(e)),
|
||||
onPressed: isActual ? null : () => _cambiarEstado(e),
|
||||
);
|
||||
}).toList()),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Evidencias del admin
|
||||
Row(children: [
|
||||
const Expanded(child: Text('Evidencias del Ayuntamiento',
|
||||
style: TextStyle(fontWeight: FontWeight.bold,
|
||||
color: AppColors.verdeAdmin, fontSize: 14))),
|
||||
Text('${_evidencias.length}',
|
||||
style: const TextStyle(color: AppColors.grisTexto, fontSize: 12)),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
if (!isClosed) Card(child: Padding(padding: const EdgeInsets.all(12), child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('Agregar evidencia de atencion',
|
||||
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
|
||||
const SizedBox(height: 8),
|
||||
TextField(controller: _pieCtrl, maxLines: 2,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Describe la evidencia (ej. Se reparo la calle)...',
|
||||
border: OutlineInputBorder(), isDense: true, filled: true, fillColor: Colors.white)),
|
||||
const SizedBox(height: 8),
|
||||
Row(children: [
|
||||
Expanded(child: _evidFoto == null
|
||||
? OutlinedButton.icon(
|
||||
onPressed: _pickFoto,
|
||||
icon: const Icon(Icons.camera_alt, size: 16),
|
||||
label: const Text('Tomar foto', style: TextStyle(fontSize: 12)),
|
||||
style: OutlinedButton.styleFrom(foregroundColor: AppColors.verdeAdmin,
|
||||
side: const BorderSide(color: AppColors.verdeAdmin)))
|
||||
: Stack(children: [
|
||||
ClipRRect(borderRadius: BorderRadius.circular(6),
|
||||
child: Image.file(_evidFoto!, height: 70, width: double.infinity, fit: BoxFit.cover)),
|
||||
Positioned(top: 4, right: 4,
|
||||
child: GestureDetector(onTap: () => setState(() => _evidFoto = null),
|
||||
child: CircleAvatar(radius: 12, backgroundColor: Colors.red.withOpacity(0.85),
|
||||
child: const Icon(Icons.close, color: Colors.white, size: 12)))),
|
||||
])),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: _loadingEv ? null : _agregarEvidencia,
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppColors.verdeAdmin,
|
||||
foregroundColor: Colors.white, minimumSize: const Size(80, 42)),
|
||||
child: _loadingEv
|
||||
? const SizedBox(width: 16, height: 16,
|
||||
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||
: const Text('Guardar', style: TextStyle(fontSize: 12))),
|
||||
]),
|
||||
]))),
|
||||
|
||||
if (_evidencias.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
..._evidencias.map((ev) => Card(margin: const EdgeInsets.only(bottom: 6),
|
||||
child: Padding(padding: const EdgeInsets.all(10), child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
const Icon(Icons.verified, color: AppColors.verdeAdmin, size: 14),
|
||||
const SizedBox(width: 4),
|
||||
Text(ev['admin_nombre'] as String? ?? 'Admin',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 11,
|
||||
color: AppColors.verdeAdmin)),
|
||||
const Spacer(),
|
||||
Text(_timeAgo(ev['fecha'] as String? ?? ''),
|
||||
style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
|
||||
]),
|
||||
const SizedBox(height: 4),
|
||||
Text(ev['pie_imagen'] as String? ?? '',
|
||||
style: const TextStyle(fontSize: 12, height: 1.4)),
|
||||
if ((ev['foto_path'] as String?) != null &&
|
||||
(ev['foto_path'] as String).isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(borderRadius: BorderRadius.circular(6),
|
||||
child: Image.file(File(ev['foto_path'] as String),
|
||||
height: 120, width: double.infinity, fit: BoxFit.cover)),
|
||||
],
|
||||
])))),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
]));
|
||||
|
||||
Widget _buildNotasTab(bool isClosed) =>
|
||||
Column(children: [
|
||||
Expanded(child: _notas.isEmpty
|
||||
? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
const Icon(Icons.sticky_note_2_outlined, color: AppColors.grisTexto, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Sin notas internas', style: TextStyle(color: AppColors.grisTexto)),
|
||||
]))
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: _notas.length,
|
||||
itemBuilder: (_, i) {
|
||||
final n = _notas[i];
|
||||
return Card(margin: const EdgeInsets.only(bottom: 8),
|
||||
color: Colors.amber.shade50,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8),
|
||||
side: BorderSide(color: Colors.amber.shade200)),
|
||||
child: Padding(padding: const EdgeInsets.all(10), child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
const Icon(Icons.person, color: AppColors.grisTexto, size: 14),
|
||||
const SizedBox(width: 4),
|
||||
Text(n['admin_nombre'] as String? ?? '',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 11)),
|
||||
const Spacer(),
|
||||
Text(_timeAgo(n['fecha'] as String? ?? ''),
|
||||
style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
|
||||
]),
|
||||
const SizedBox(height: 4),
|
||||
Text(n['nota'] as String? ?? '',
|
||||
style: const TextStyle(fontSize: 13, height: 1.4)),
|
||||
])));
|
||||
})),
|
||||
if (!isClosed) Container(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), color: Colors.white,
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('Nueva nota interna (solo visible para admins)',
|
||||
style: TextStyle(fontSize: 11, color: AppColors.grisTexto)),
|
||||
const SizedBox(height: 6),
|
||||
Row(children: [
|
||||
Expanded(child: TextField(controller: _notaCtrl, maxLines: 2,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Escribe una nota interna...',
|
||||
border: OutlineInputBorder(), isDense: true,
|
||||
filled: true, fillColor: AppColors.grisFondo))),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: _loadingNota ? null : _agregarNota,
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.amber.shade700,
|
||||
foregroundColor: Colors.white, minimumSize: const Size(70, 48)),
|
||||
child: _loadingNota
|
||||
? const SizedBox(width: 16, height: 16,
|
||||
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||
: const Icon(Icons.add, size: 20)),
|
||||
]),
|
||||
])),
|
||||
]);
|
||||
|
||||
String _timeAgo(String fechaStr) {
|
||||
final f = DateTime.tryParse(fechaStr);
|
||||
if (f == null) return '';
|
||||
final diff = DateTime.now().difference(f);
|
||||
if (diff.inMinutes < 1) return 'Ahora';
|
||||
if (diff.inMinutes < 60) return 'Hace ${diff.inMinutes} min';
|
||||
if (diff.inHours < 24) return 'Hace ${diff.inHours}h';
|
||||
return '${f.day}/${f.month}/${f.year}';
|
||||
}
|
||||
|
||||
@override void dispose() { _tabs.dispose(); _notaCtrl.dispose(); _pieCtrl.dispose(); super.dispose(); }
|
||||
}
|
||||
|
||||
class _EstadoBadge extends StatelessWidget {
|
||||
final String estado; final Color color;
|
||||
const _EstadoBadge({required this.estado, required this.color});
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(color: color.withOpacity(0.12), borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: color.withOpacity(0.4))),
|
||||
child: Text(estado, style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: color)));
|
||||
}
|
||||
@@ -35,6 +35,7 @@ class _CitizenHomeScreenState extends State<CitizenHomeScreen> {
|
||||
_HomeTab(auth: auth, sim: sim),
|
||||
const CitizenGuiaScreen(),
|
||||
const CitizenReporteScreen(),
|
||||
const ChatbotScreen(),
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
@@ -60,6 +61,8 @@ class _CitizenHomeScreenState extends State<CitizenHomeScreen> {
|
||||
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'),
|
||||
NavigationDestination(icon:Icon(Icons.support_agent_outlined),
|
||||
selectedIcon:Icon(Icons.support_agent,color:AppColors.guindaPrimary),label:'Asistente'),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../shared/reporte_chat_screen.dart';
|
||||
import '../../database/db_helper.dart';
|
||||
import '../../models/models.dart';
|
||||
import '../../services/auth_service.dart';
|
||||
@@ -66,20 +67,23 @@ class _CitizenReporteScreenState extends State<CitizenReporteScreen> {
|
||||
|
||||
Future<void> _send() async {
|
||||
final auth = context.read<AuthService>();
|
||||
if (auth.currentUser == null || _desc.text.trim().isEmpty) {
|
||||
if (auth.currentUser == null) return;
|
||||
if (_desc.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('Describe el problema'),
|
||||
content: Text('Describe el problema para poder enviar el reporte'),
|
||||
backgroundColor: AppColors.rojoError));
|
||||
return;
|
||||
}
|
||||
setState(() => _loading = true);
|
||||
|
||||
try {
|
||||
// Insertar reporte directo en la BD
|
||||
final db = await DbHelper.database;
|
||||
await db.insert('reportes', {
|
||||
final id = await db.insert('reportes', {
|
||||
'user_id': auth.currentUser!.id,
|
||||
'tipo': _tipo,
|
||||
'descripcion': _desc.text.trim(),
|
||||
'colonia': auth.primaryDomicilio?.colonia ?? '',
|
||||
'colonia': auth.primaryDomicilio?.colonia ?? 'Sin colonia',
|
||||
'route_id': auth.primaryDomicilio?.routeId ?? '',
|
||||
'fecha': DateTime.now().toIso8601String(),
|
||||
'estado': 'PENDIENTE',
|
||||
@@ -87,11 +91,20 @@ class _CitizenReporteScreenState extends State<CitizenReporteScreen> {
|
||||
'foto_path': _foto?.path,
|
||||
});
|
||||
|
||||
if (id <= 0) throw Exception('No se pudo guardar el reporte');
|
||||
|
||||
await _load();
|
||||
if (!mounted) return;
|
||||
setState(() { _loading = false; _sent = true; _desc.clear(); _foto = null; });
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
_desc.clear();
|
||||
setState(() { _foto = null; _loading = false; _sent = true; });
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
if (mounted) setState(() => _sent = false);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('Error al enviar: $e'),
|
||||
backgroundColor: AppColors.rojoError));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -104,12 +117,18 @@ class _CitizenReporteScreenState extends State<CitizenReporteScreen> {
|
||||
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,
|
||||
const Icon(Icons.check_circle, color: AppColors.verdeExito, size: 72),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Reporte enviado', style: TextStyle(fontSize: 22,
|
||||
fontWeight: FontWeight.bold, color: AppColors.verdeExito)),
|
||||
const Text('El Ayuntamiento lo revisara pronto.',
|
||||
const SizedBox(height: 8),
|
||||
const Text('El Ayuntamiento revisara tu reporte pronto.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: AppColors.grisTexto)),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Podras chatear con ellos desde "Mis Reportes".',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: AppColors.grisTexto, fontSize: 12)),
|
||||
]))
|
||||
: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(children: [
|
||||
Card(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
@@ -190,21 +209,45 @@ class _CitizenReporteScreenState extends State<CitizenReporteScreen> {
|
||||
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,
|
||||
..._reportes.map((r) {
|
||||
final isClosed = r.estado == 'COMPLETADO';
|
||||
final id = r.id ?? 0;
|
||||
final folio = 'RPT-${id.toString().padLeft(5, "0")}';
|
||||
return Card(margin: const EdgeInsets.only(bottom: 6),
|
||||
child: Column(children: [
|
||||
ListTile(dense: true,
|
||||
leading: CircleAvatar(backgroundColor: AppColors.guindaPrimary, radius: 16,
|
||||
child: const 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,
|
||||
title: Row(children: [
|
||||
Expanded(child: Text(_tipos[r.tipo] ?? r.tipo,
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600))),
|
||||
Text(folio, style: const TextStyle(fontSize: 9, color: AppColors.grisTexto)),
|
||||
]),
|
||||
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: _estadoColor(r.estado).withOpacity(0.15),
|
||||
decoration: BoxDecoration(color: _estadoColor(r.estado).withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(10)),
|
||||
child: Text(r.estado, style: TextStyle(fontSize: 9,
|
||||
color: _estadoColor(r.estado), fontWeight: FontWeight.bold)))))),
|
||||
child: Text(r.estado.replaceAll('_',' '),
|
||||
style: TextStyle(fontSize: 9, color: _estadoColor(r.estado),
|
||||
fontWeight: FontWeight.bold)))),
|
||||
if (r.id != null) Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
|
||||
child: SizedBox(width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => Navigator.push(context, MaterialPageRoute(
|
||||
builder: (_) => ReporteChatScreen(
|
||||
reporteId: r.id!, folio: folio, isClosed: isClosed))),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: isClosed ? AppColors.grisTexto : AppColors.guindaPrimary,
|
||||
side: BorderSide(color: isClosed ? AppColors.grisTexto : AppColors.guindaPrimary),
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
minimumSize: Size.zero),
|
||||
icon: Icon(isClosed ? Icons.lock : Icons.chat_bubble_outline, size: 14),
|
||||
label: Text(isClosed ? 'Chat cerrado' : 'Escribir al Ayuntamiento',
|
||||
style: const TextStyle(fontSize: 11))))),
|
||||
]));
|
||||
}),
|
||||
],
|
||||
])),
|
||||
);
|
||||
|
||||
174
lib/screens/shared/reporte_chat_screen.dart
Normal file
174
lib/screens/shared/reporte_chat_screen.dart
Normal file
@@ -0,0 +1,174 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../database/db_helper.dart';
|
||||
import '../../services/auth_service.dart';
|
||||
|
||||
class ReporteChatScreen extends StatefulWidget {
|
||||
final int reporteId;
|
||||
final String folio;
|
||||
final bool isClosed;
|
||||
const ReporteChatScreen({super.key, required this.reporteId,
|
||||
required this.folio, this.isClosed = false});
|
||||
@override State<ReporteChatScreen> createState() => _ReporteChatScreenState();
|
||||
}
|
||||
|
||||
class _ReporteChatScreenState extends State<ReporteChatScreen> {
|
||||
final _ctrl = TextEditingController();
|
||||
final _scroll = ScrollController();
|
||||
List<Map<String, dynamic>> _msgs = [];
|
||||
bool _loading = true;
|
||||
Timer? _timer;
|
||||
|
||||
@override void initState() { super.initState(); _load();
|
||||
_timer = Timer.periodic(const Duration(seconds: 5), (_) => _load()); }
|
||||
|
||||
Future<void> _load() async {
|
||||
final auth = context.read<AuthService>();
|
||||
final rol = auth.currentUser?.rol ?? 'CIUDADANO';
|
||||
final msgs = await DbHelper.getChatMsgs(widget.reporteId);
|
||||
await DbHelper.markChatRead(widget.reporteId, rol);
|
||||
if (mounted) {
|
||||
setState(() { _msgs = msgs; _loading = false; });
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scroll.hasClients) _scroll.animateTo(_scroll.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _send() async {
|
||||
final text = _ctrl.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
final auth = context.read<AuthService>();
|
||||
final user = auth.currentUser;
|
||||
if (user == null) return;
|
||||
_ctrl.clear();
|
||||
await DbHelper.insertChatMsg(widget.reporteId, user.id!, user.rol, text);
|
||||
await _load();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = context.watch<AuthService>();
|
||||
final myRol = auth.currentUser?.rol ?? 'CIUDADANO';
|
||||
final isAdmin = myRol == 'ADMINISTRADOR';
|
||||
final accent = isAdmin ? AppColors.verdeAdmin : AppColors.guindaPrimary;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.grisFondo,
|
||||
appBar: AppBar(
|
||||
backgroundColor: accent, foregroundColor: Colors.white,
|
||||
title: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('Chat del Reporte', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
|
||||
Text('Folio: ${widget.folio}', style: const TextStyle(fontSize: 11, color: Colors.white70)),
|
||||
]),
|
||||
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
||||
child: Container(height: 4, color: AppColors.dorado)),
|
||||
actions: [if (widget.isClosed) Container(margin: const EdgeInsets.only(right: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(color: AppColors.verdeExito, borderRadius: BorderRadius.circular(12)),
|
||||
child: const Text('COMPLETADO', style: TextStyle(fontSize: 10,
|
||||
color: Colors.white, fontWeight: FontWeight.bold)))],
|
||||
),
|
||||
body: Column(children: [
|
||||
if (widget.isClosed) Container(
|
||||
width: double.infinity, padding: const EdgeInsets.all(10),
|
||||
color: AppColors.verdeExito.withOpacity(0.08),
|
||||
child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Icon(Icons.lock, size: 14, color: AppColors.verdeExito),
|
||||
SizedBox(width: 6),
|
||||
Text('Reporte completado. Chat cerrado.',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.verdeExito, fontWeight: FontWeight.w600)),
|
||||
])),
|
||||
Expanded(child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _msgs.isEmpty
|
||||
? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Icon(Icons.chat_bubble_outline, size: 48, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 12),
|
||||
Text(isAdmin ? 'Inicia la conversacion con el ciudadano'
|
||||
: 'Escribe tu mensaje al Ayuntamiento',
|
||||
style: TextStyle(color: Colors.grey.shade500)),
|
||||
]))
|
||||
: ListView.builder(
|
||||
controller: _scroll, padding: const EdgeInsets.all(12),
|
||||
itemCount: _msgs.length,
|
||||
itemBuilder: (_, i) {
|
||||
final m = _msgs[i];
|
||||
final rol = m['rol'] as String;
|
||||
final isMe = rol == myRol;
|
||||
final fecha = DateTime.tryParse(m['fecha'] as String? ?? '');
|
||||
final hora = fecha != null
|
||||
? '${fecha.hour.toString().padLeft(2,'0')}:${fecha.minute.toString().padLeft(2,'0')}' : '';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Row(
|
||||
mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (!isMe) ...[
|
||||
CircleAvatar(radius: 14,
|
||||
backgroundColor: (rol=='ADMINISTRADOR' ? AppColors.verdeAdmin : AppColors.guindaPrimary).withOpacity(0.15),
|
||||
child: Icon(rol=='ADMINISTRADOR' ? Icons.admin_panel_settings : Icons.person,
|
||||
size: 14, color: rol=='ADMINISTRADOR' ? AppColors.verdeAdmin : AppColors.guindaPrimary)),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
Flexible(child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isMe ? accent : Colors.white,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(16), topRight: const Radius.circular(16),
|
||||
bottomLeft: Radius.circular(isMe ? 16 : 4),
|
||||
bottomRight: Radius.circular(isMe ? 4 : 16),
|
||||
),
|
||||
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.07), blurRadius: 4)],
|
||||
),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
if (!isMe) Text(rol=='ADMINISTRADOR' ? 'Ayuntamiento' : 'Ciudadano',
|
||||
style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold,
|
||||
color: rol=='ADMINISTRADOR' ? AppColors.verdeAdmin : AppColors.guindaPrimary)),
|
||||
Text(m['mensaje'] as String? ?? '',
|
||||
style: TextStyle(fontSize: 13, height: 1.4,
|
||||
color: isMe ? Colors.white : AppColors.negroTexto)),
|
||||
Text(hora, style: TextStyle(fontSize: 9,
|
||||
color: isMe ? Colors.white60 : AppColors.grisTexto)),
|
||||
]),
|
||||
)),
|
||||
if (isMe) const SizedBox(width: 6),
|
||||
],
|
||||
),
|
||||
);
|
||||
})),
|
||||
if (!widget.isClosed) Container(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
|
||||
decoration: BoxDecoration(color: Colors.white,
|
||||
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 4, offset: const Offset(0,-2))]),
|
||||
child: SafeArea(top: false, child: Row(children: [
|
||||
Expanded(child: TextField(
|
||||
controller: _ctrl, maxLines: 3, minLines: 1,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(
|
||||
hintText: isAdmin ? 'Responde al ciudadano...' : 'Escribe al Ayuntamiento...',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(24), borderSide: BorderSide.none),
|
||||
filled: true, fillColor: AppColors.grisFondo,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), isDense: true),
|
||||
)),
|
||||
const SizedBox(width: 8),
|
||||
CircleAvatar(radius: 22, backgroundColor: accent,
|
||||
child: IconButton(icon: const Icon(Icons.send, color: Colors.white, size: 18), onPressed: _send)),
|
||||
])))
|
||||
else Container(padding: const EdgeInsets.all(14), color: Colors.white,
|
||||
child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Icon(Icons.lock_outline, size: 16, color: AppColors.grisTexto),
|
||||
SizedBox(width: 6),
|
||||
Text('Chat cerrado', style: TextStyle(color: AppColors.grisTexto, fontSize: 12)),
|
||||
])),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
@override void dispose() { _ctrl.dispose(); _scroll.dispose(); _timer?.cancel(); super.dispose(); }
|
||||
}
|
||||
Reference in New Issue
Block a user