diff --git a/lib/database/db_helper.dart b/lib/database/db_helper.dart index 9cfdf72..9746bea 100644 --- a/lib/database/db_helper.dart +++ b/lib/database/db_helper.dart @@ -12,7 +12,8 @@ class DbHelper { static Future _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 _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 _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 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 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>> getReporteNotas(int reporteId) async => + (await database).query('reporte_notas', + where: 'reporte_id=?', whereArgs: [reporteId], orderBy: 'fecha ASC'); + + // ── EVIDENCIAS DEL ADMIN ───────────────────────────────────────────────── + static Future 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>> getReporteEvidencias(int reporteId) async => + (await database).query('reporte_evidencias', + where: 'reporte_id=?', whereArgs: [reporteId], orderBy: 'fecha ASC'); + + // ── CHAT POR REPORTE ───────────────────────────────────────────────────── + static Future 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>> getChatMsgs(int reporteId) async => + (await database).query('reporte_chat', + where: 'reporte_id=?', whereArgs: [reporteId], orderBy: 'fecha ASC'); + + static Future 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 markChatRead(int reporteId, String rolLector) async => + (await database).update('reporte_chat', {'leido': 1}, + where: 'reporte_id=? AND rol!=?', whereArgs: [reporteId, rolLector]); } diff --git a/lib/screens/admin/admin_dashboard_screen.dart b/lib/screens/admin/admin_dashboard_screen.dart index d140d6a..ab67fbc 100644 --- a/lib/screens/admin/admin_dashboard_screen.dart +++ b/lib/screens/admin/admin_dashboard_screen.dart @@ -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 { 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 { 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> _reportes = []; + List> _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 _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> 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( - 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 _conductores = []; - UserModel? _sel; - List _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 _load() async { - final c = await DbHelper.getUsersByRol('CONDUCTOR'); - if (mounted) setState(() => _conductores = c); - } - - Future _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 dias) { - for (final dia in dias) { - try { return _asigs.firstWhere((a) => a.diaSemana == dia); } catch (_) {} + 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; } - return null; } - // Guardar asignación para todos los días del grupo - Future _saveGrupo(List 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 _estadoLabel(String s) => s.replaceAll('_', ' '); + + String _folio(int id) => 'RPT-${id.toString().padLeft(5, "0")}'; @override Widget build(BuildContext context) => Scaffold( appBar: AppBar(automaticallyImplyLeading: false, backgroundColor: AppColors.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( - 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))), - ]))), - ]))), - ], - ], - ])), + 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: _estadoColor(estado).withOpacity(0.3))), + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: () async { + await Navigator.push(ctx, MaterialPageRoute( + builder: (_) => AdminReporteDetalleScreen(reporte: r))); + _load(); + }, + child: Padding(padding: const EdgeInsets.all(12), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Text(folio, style: const TextStyle(fontWeight: FontWeight.bold, + fontSize: 13, color: AppColors.verdeAdmin)), + const SizedBox(width: 8), + Container(padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: _estadoColor(estado).withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: _estadoColor(estado).withOpacity(0.4))), + child: Text(_estadoLabel(estado), + style: TextStyle(fontSize: 9, fontWeight: FontWeight.bold, + color: _estadoColor(estado)))), + const Spacer(), + if (fotoPath != null && fotoPath.isNotEmpty) + const Icon(Icons.photo_camera, size: 14, color: AppColors.azulInfo), + const SizedBox(width: 4), + const Icon(Icons.chevron_right, color: AppColors.grisTexto, size: 18), + ]), + const SizedBox(height: 4), + Text('$nombre — $colonia', + style: const TextStyle(color: AppColors.grisTexto, fontSize: 11)), + const SizedBox(height: 2), + Text(desc, style: const TextStyle(fontSize: 12), + maxLines: 2, overflow: TextOverflow.ellipsis), + ])))); + }))), + ]), ); } -// 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 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( - 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( - 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 _alertas = []; - bool _soloActivas = false; - - @override void initState(){ super.initState(); _load(); } - - Future _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 _conductores = []; + UserModel? _sel; + List _asigs = []; + + static const _grupoA = ['LUNES', 'MIERCOLES', 'VIERNES']; + static const _grupoB = ['MARTES', 'JUEVES', 'SABADO']; + + @override void initState() { super.initState(); _load(); } + + Future _load() async { + final c = await DbHelper.getUsersByRol('CONDUCTOR'); + if (mounted) setState(() => _conductores = c); + } + + Future _loadAsigs(int id) async { + final a = await DbHelper.getAsignacionesByConductor(id); + if (mounted) setState(() => _asigs = a); + } + + AssignmentModel? _getGrupo(List dias) { + for (final dia in dias) { + try { return _asigs.firstWhere((a) => a.diaSemana == dia); } catch (_) {} + } + return null; + } + + Future _saveGrupo(List 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( + 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 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( + 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( + 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 _alertas = []; + bool _loading = true; + + @override void initState() { super.initState(); _load(); } + + Future _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), + ])))); +} diff --git a/lib/screens/admin/admin_reporte_detalle_screen.dart b/lib/screens/admin/admin_reporte_detalle_screen.dart new file mode 100644 index 0000000..7001eab --- /dev/null +++ b/lib/screens/admin/admin_reporte_detalle_screen.dart @@ -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 reporte; + const AdminReporteDetalleScreen({super.key, required this.reporte}); + @override State createState() => _AdminReporteDetalleScreenState(); +} + +class _AdminReporteDetalleScreenState extends State + with SingleTickerProviderStateMixin { + late TabController _tabs; + late Map _r; + List> _notas = []; + List> _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 _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 _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 _agregarNota() async { + if (_notaCtrl.text.trim().isEmpty) return; + final auth = context.read(); + 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 _agregarEvidencia() async { + if (_pieCtrl.text.trim().isEmpty && _evidFoto == null) return; + final auth = context.read(); + 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 _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))); +} diff --git a/lib/screens/citizen/citizen_home_screen.dart b/lib/screens/citizen/citizen_home_screen.dart index 9df478e..8356285 100644 --- a/lib/screens/citizen/citizen_home_screen.dart +++ b/lib/screens/citizen/citizen_home_screen.dart @@ -35,6 +35,7 @@ class _CitizenHomeScreenState extends State { _HomeTab(auth: auth, sim: sim), const CitizenGuiaScreen(), const CitizenReporteScreen(), + const ChatbotScreen(), ]; return Scaffold( @@ -60,6 +61,8 @@ class _CitizenHomeScreenState extends State { 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'), ], ), ); diff --git a/lib/screens/citizen/citizen_reporte_screen.dart b/lib/screens/citizen/citizen_reporte_screen.dart index 46ac684..835918f 100644 --- a/lib/screens/citizen/citizen_reporte_screen.dart +++ b/lib/screens/citizen/citizen_reporte_screen.dart @@ -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,32 +67,44 @@ class _CitizenReporteScreenState extends State { Future _send() async { final auth = context.read(); - 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); - final db = await DbHelper.database; - await db.insert('reportes', { - 'user_id': auth.currentUser!.id, - 'tipo': _tipo, - 'descripcion': _desc.text.trim(), - 'colonia': auth.primaryDomicilio?.colonia ?? '', - 'route_id': auth.primaryDomicilio?.routeId ?? '', - 'fecha': DateTime.now().toIso8601String(), - 'estado': 'PENDIENTE', - 'calificacion': _calif, - 'foto_path': _foto?.path, - }); + try { + // Insertar reporte directo en la BD + final db = await DbHelper.database; + final id = await db.insert('reportes', { + 'user_id': auth.currentUser!.id, + 'tipo': _tipo, + 'descripcion': _desc.text.trim(), + 'colonia': auth.primaryDomicilio?.colonia ?? 'Sin colonia', + 'route_id': auth.primaryDomicilio?.routeId ?? '', + 'fecha': DateTime.now().toIso8601String(), + 'estado': 'PENDIENTE', + 'calificacion': _calif, + 'foto_path': _foto?.path, + }); - await _load(); - if (!mounted) return; - setState(() { _loading = false; _sent = true; _desc.clear(); _foto = null; }); - await Future.delayed(const Duration(seconds: 2)); - if (mounted) setState(() => _sent = false); + if (id <= 0) throw Exception('No se pudo guardar el reporte'); + + await _load(); + _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 { 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 { 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))))), + ])); + }), ], ])), ); diff --git a/lib/screens/shared/reporte_chat_screen.dart b/lib/screens/shared/reporte_chat_screen.dart new file mode 100644 index 0000000..ca5c1e7 --- /dev/null +++ b/lib/screens/shared/reporte_chat_screen.dart @@ -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 createState() => _ReporteChatScreenState(); +} + +class _ReporteChatScreenState extends State { + final _ctrl = TextEditingController(); + final _scroll = ScrollController(); + List> _msgs = []; + bool _loading = true; + Timer? _timer; + + @override void initState() { super.initState(); _load(); + _timer = Timer.periodic(const Duration(seconds: 5), (_) => _load()); } + + Future _load() async { + final auth = context.read(); + 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 _send() async { + final text = _ctrl.text.trim(); + if (text.isEmpty) return; + final auth = context.read(); + 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(); + 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(); } +}