import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/services/auth_controller.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_widgets.dart'; import 'data/admin_service.dart'; import 'models/admin_driver.dart'; import 'models/admin_incident.dart'; import 'models/admin_route.dart'; import 'models/admin_unit.dart'; import 'models/admin_user.dart'; import 'providers/admin_providers.dart'; class AdminScreen extends ConsumerStatefulWidget { const AdminScreen({super.key}); @override ConsumerState createState() => _AdminScreenState(); } class _AdminScreenState extends ConsumerState with SingleTickerProviderStateMixin { late final TabController _tabController; int _activeTab = 0; @override void initState() { super.initState(); _tabController = TabController(length: 3, vsync: this) ..addListener(() { if (!_tabController.indexIsChanging) { setState(() => _activeTab = _tabController.index); } }); } @override void dispose() { _tabController.dispose(); super.dispose(); } AdminService get _service => ref.read(adminServiceProvider); void _snack(String msg, {bool error = false}) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(msg), backgroundColor: error ? AppTheme.danger : AppTheme.primary, ), ); } Future _handleAdd() async { switch (_activeTab) { case 0: await _showUserForm(); break; case 2: await _showUnitForm(); break; } } Future _handleSimulationTick() async { try { final events = await _service.simulationTick(); if (events.isEmpty) { _snack('Tick enviado · sin eventos nuevos en esta posición'); } else { final tipos = events .map((e) => e['event']?.toString() ?? '?') .toSet() .join(', '); _snack('Tick enviado · ${events.length} push FCM ($tipos)'); } } catch (e) { _snack('No se pudo avanzar la simulación: $e', error: true); } } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppTheme.background, appBar: AppBar( title: const Text('Panel de administración'), actions: [ IconButton( tooltip: 'Avanzar simulación (envía push FCM)', icon: const Icon(Icons.play_circle_fill_rounded), onPressed: _handleSimulationTick, ), IconButton( tooltip: 'Refrescar', icon: const Icon(Icons.refresh), onPressed: () { ref.invalidate(adminUsersProvider); ref.invalidate(adminRoutesProvider); ref.invalidate(adminUnitsProvider); ref.invalidate(adminDriversProvider); }, ), IconButton( tooltip: 'Cerrar sesión', icon: const Icon(Icons.logout), onPressed: () async { await ref.read(authControllerProvider.notifier).logout(); if (mounted) context.go('/login'); }, ), ], bottom: TabBar( controller: _tabController, indicatorColor: Colors.white, labelColor: Colors.white, unselectedLabelColor: Colors.white70, tabs: const [ Tab(text: 'Usuarios'), Tab(text: 'Rutas'), Tab(text: 'Unidades'), ], ), ), body: TabBarView( controller: _tabController, children: const [_UsersTab(), _RoutesTab(), _TrucksTab()], ), floatingActionButton: _activeTab == 1 ? null // Oculta el botón flotante en la pestaña de Rutas : FloatingActionButton.extended( onPressed: _handleAdd, backgroundColor: AppTheme.primary, icon: const Icon(Icons.add), label: Text(_activeTab == 0 ? 'Nuevo usuario' : 'Nueva unidad'), ), ); } // ── Formulario usuario ────────────────────────────────────────────────────── Future _showUserForm({AdminUserModel? user}) async { final isEdit = user != null; final nombre = TextEditingController(text: user?.name ?? ''); final email = TextEditingController(text: user?.email ?? ''); final telefono = TextEditingController(text: user?.phone ?? ''); final password = TextEditingController(); String role = user?.role ?? 'citizen'; final formKey = GlobalKey(); final saved = await showDialog( context: context, builder: (ctx) { return StatefulBuilder( builder: (ctx, setStateDialog) { return AlertDialog( backgroundColor: AppTheme.surface, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppTheme.radiusLg), ), title: Text(isEdit ? 'Editar usuario' : 'Nuevo usuario'), content: Form( key: formKey, child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ _textField(nombre, 'Nombre', required: true), const SizedBox(height: 10), _textField( email, 'Email', keyboardType: TextInputType.emailAddress, ), const SizedBox(height: 10), if (!isEdit) ...[ _textField( telefono, 'Teléfono', keyboardType: TextInputType.phone, ), const SizedBox(height: 10), _textField( password, 'Contraseña (mín. 6)', obscure: true, required: true, validator: (v) => (v == null || v.length < 6) ? 'Mínimo 6 caracteres' : null, ), const SizedBox(height: 10), ], DropdownButtonFormField( initialValue: role, decoration: InputDecoration( labelText: 'Rol', border: OutlineInputBorder( borderRadius: BorderRadius.circular( AppTheme.radiusMd, ), ), ), items: const [ DropdownMenuItem( value: 'citizen', child: Text('Ciudadano'), ), DropdownMenuItem( value: 'driver', child: Text('Conductor'), ), DropdownMenuItem( value: 'admin', child: Text('Administrador'), ), ], onChanged: (v) { if (v != null) setStateDialog(() => role = v); }, ), ], ), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancelar'), ), ElevatedButton( onPressed: () async { if (!formKey.currentState!.validate()) return; try { if (isEdit) { await _service.updateUser( user.id, name: nombre.text.trim(), email: email.text.trim().isEmpty ? null : email.text.trim(), role: role, ); } else { if (email.text.trim().isEmpty && telefono.text.trim().isEmpty) { _snack('Email o teléfono es requerido', error: true); return; } await _service.createUser( name: nombre.text.trim(), password: password.text, email: email.text.trim().isEmpty ? null : email.text.trim(), phone: telefono.text.trim().isEmpty ? null : telefono.text.trim(), role: role, ); } if (ctx.mounted) Navigator.pop(ctx, true); } catch (e) { _snack('Error: ${_errMsg(e)}', error: true); } }, child: const Text('Guardar'), ), ], ); }, ); }, ); if (saved == true) { ref.invalidate(adminUsersProvider); ref.invalidate(adminDriversProvider); _snack(isEdit ? 'Usuario actualizado' : 'Usuario creado'); } } // ── Formulario para Reasignar Ruta (Solo Vespertinas) ─────────────────────── Future _showReassignRoute(AdminRouteModel route) async { final units = ref .read(adminUnitsProvider) .maybeWhen( data: (u) => u.where((x) => x.status == 'active').toList(), orElse: () => [], ); int? selectedUnitId; final formKey = GlobalKey(); final saved = await showDialog( context: context, builder: (ctx) { return StatefulBuilder( builder: (ctx, setStateDialog) { return AlertDialog( backgroundColor: AppTheme.surface, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppTheme.radiusLg), ), title: const Text('Reasignar Unidad'), content: Form( key: formKey, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Ruta: ${route.displayName}', style: const TextStyle(fontWeight: FontWeight.w600), ), const SizedBox(height: 8), const Text( 'Selecciona una unidad activa para cubrir este turno vespertino:', style: TextStyle( fontSize: 13, color: AppTheme.textSecondary, ), ), const SizedBox(height: 16), DropdownButtonFormField( decoration: InputDecoration( labelText: 'Nueva Unidad', border: OutlineInputBorder( borderRadius: BorderRadius.circular( AppTheme.radiusMd, ), ), ), validator: (v) => v == null ? 'Selecciona una unidad' : null, items: units.map((u) { return DropdownMenuItem( value: u.id, child: Text('${u.displayPlate} (#${u.id})'), ); }).toList(), onChanged: (v) => setStateDialog(() => selectedUnitId = v), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancelar'), ), ElevatedButton( onPressed: () async { if (!formKey.currentState!.validate()) return; try { await _service.updateRoute( route.id, truckId: selectedUnitId, status: 'reasignada', ); if (ctx.mounted) Navigator.pop(ctx, true); } catch (e) { _snack('Error: ${_errMsg(e)}', error: true); } }, child: const Text('Confirmar'), ), ], ); }, ); }, ); if (saved == true) { ref.invalidate(adminRoutesProvider); _snack('Ruta reasignada exitosamente'); } } // ── Formulario unidad ─────────────────────────────────────────────────────── Future _showUnitForm({AdminUnitModel? unit}) async { final isEdit = unit != null; final idCtrl = TextEditingController(text: unit?.id.toString() ?? ''); final plate = TextEditingController(text: unit?.plate ?? ''); String status = unit?.status ?? 'active'; final formKey = GlobalKey(); final saved = await showDialog( context: context, builder: (ctx) { return StatefulBuilder( builder: (ctx, setStateDialog) { return AlertDialog( backgroundColor: AppTheme.surface, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppTheme.radiusLg), ), title: Text(isEdit ? 'Editar unidad' : 'Nueva unidad'), content: Form( key: formKey, child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ _textField( idCtrl, 'ID numérico (ej. 101)', keyboardType: TextInputType.number, required: true, enabled: !isEdit, validator: (v) { if (v == null || v.trim().isEmpty) return 'Requerido'; if (int.tryParse(v) == null) return 'Debe ser numérico'; return null; }, ), const SizedBox(height: 10), _textField(plate, 'Placa'), const SizedBox(height: 10), DropdownButtonFormField( initialValue: status, decoration: InputDecoration( labelText: 'Estado', border: OutlineInputBorder( borderRadius: BorderRadius.circular( AppTheme.radiusMd, ), ), ), items: const [ DropdownMenuItem( value: 'active', child: Text('Activo'), ), DropdownMenuItem( value: 'inactive', child: Text('Inactivo'), ), DropdownMenuItem( value: 'maintenance', child: Text('Mantenimiento'), ), ], onChanged: (v) { if (v != null) setStateDialog(() => status = v); }, ), ], ), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancelar'), ), ElevatedButton( onPressed: () async { if (!formKey.currentState!.validate()) return; try { if (isEdit) { await _service.updateUnit( unit.id, plate: plate.text.trim().isEmpty ? null : plate.text.trim(), status: status, ); } else { await _service.createUnit( id: int.parse(idCtrl.text.trim()), plate: plate.text.trim().isEmpty ? null : plate.text.trim(), status: status, ); } if (ctx.mounted) Navigator.pop(ctx, true); } catch (e) { _snack('Error: ${_errMsg(e)}', error: true); } }, child: const Text('Guardar'), ), ], ); }, ); }, ); if (saved == true) { ref.invalidate(adminUnitsProvider); ref.invalidate(adminRoutesProvider); _snack(isEdit ? 'Unidad actualizada' : 'Unidad creada'); } } // ── Helpers ───────────────────────────────────────────────────────────────── Widget _textField( TextEditingController controller, String label, { TextInputType keyboardType = TextInputType.text, bool obscure = false, bool required = false, bool enabled = true, String? Function(String?)? validator, }) { return TextFormField( controller: controller, keyboardType: keyboardType, obscureText: obscure, enabled: enabled, validator: validator ?? (required ? (v) => (v == null || v.trim().isEmpty) ? 'Campo requerido' : null : null), decoration: InputDecoration( labelText: label, border: OutlineInputBorder( borderRadius: BorderRadius.circular(AppTheme.radiusMd), ), ), ); } String _errMsg(Object e) { final s = e.toString(); return s.length > 220 ? '${s.substring(0, 220)}…' : s; } } // ── Tabs ────────────────────────────────────────────────────────────────────── class _UsersTab extends ConsumerWidget { const _UsersTab(); @override Widget build(BuildContext context, WidgetRef ref) { final async = ref.watch(adminUsersProvider); return async.when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => _ErrorView( message: e.toString(), onRetry: () => ref.invalidate(adminUsersProvider), ), data: (users) { if (users.isEmpty) { return const _EmptyView('No hay usuarios registrados.'); } return ListView.separated( padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), itemCount: users.length, separatorBuilder: (_, __) => const SizedBox(height: 12), itemBuilder: (context, i) { final u = users[i]; return AppCard( child: Row( children: [ CircleAvatar( backgroundColor: AppTheme.primaryLight, foregroundColor: AppTheme.primary, child: Text(u.initials), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( u.displayName, style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 4), if (u.email != null && u.email!.isNotEmpty) Text( u.email!, style: const TextStyle( fontSize: 13, color: AppTheme.textSecondary, ), ), if (u.phone != null && u.phone!.isNotEmpty) Text(u.phone!, style: const TextStyle(fontSize: 13)), const SizedBox(height: 4), _roleBadge(u.role), ], ), ), IconButton( icon: const Icon( Icons.edit_outlined, color: AppTheme.primary, ), onPressed: () { final state = context .findAncestorStateOfType<_AdminScreenState>(); state?._showUserForm(user: u); }, ), IconButton( icon: const Icon( Icons.delete_outline, color: AppTheme.danger, ), onPressed: () => _confirmAndDelete( context, tipo: 'usuario', onConfirm: () async { await ref.read(adminServiceProvider).deleteUser(u.id); ref.invalidate(adminUsersProvider); ref.invalidate(adminDriversProvider); }, ), ), ], ), ); }, ); }, ); } Widget _roleBadge(String role) { switch (role) { case 'admin': return AppStatusBadge.amber('Administrador'); case 'driver': return AppStatusBadge.green('Conductor'); default: return AppStatusBadge.gray('Ciudadano'); } } } class _RoutesTab extends ConsumerWidget { const _RoutesTab(); @override Widget build(BuildContext context, WidgetRef ref) { final async = ref.watch(adminRoutesProvider); final units = ref .watch(adminUnitsProvider) .maybeWhen(data: (u) => u, orElse: () => []); return async.when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => _ErrorView( message: e.toString(), onRetry: () => ref.invalidate(adminRoutesProvider), ), data: (routes) { if (routes.isEmpty) { return const _EmptyView('No hay rutas registradas.'); } return ListView.separated( padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), itemCount: routes.length, separatorBuilder: (_, __) => const SizedBox(height: 12), itemBuilder: (context, i) { final r = routes[i]; AdminUnitModel? unit; if (r.truckId != null) { for (final u in units) { if (u.id == r.truckId) { unit = u; break; } } } return AppCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( r.displayName, style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, ), ), ), _routeStatusBadge(r.status), ], ), const SizedBox(height: 6), Text( 'ID: ${r.id}', style: const TextStyle( fontSize: 12, color: AppTheme.textSecondary, ), ), if (r.turno != null) Text( 'Turno: ${r.turno}', style: const TextStyle(fontSize: 13), ), Text( 'Unidad: ${unit?.displayPlate ?? (r.truckId == null ? 'Sin asignar' : '#${r.truckId}')}', style: const TextStyle( fontSize: 13, color: AppTheme.textSecondary, ), ), Text( 'Posición actual: ${r.currentPositionId}/8', style: const TextStyle( fontSize: 12, color: AppTheme.textSecondary, ), ), if (unit != null && (unit.status == 'inactive' || unit.status == 'maintenance') && r.turno?.toLowerCase() == 'vespertino') ...[ const SizedBox(height: 12), Align( alignment: Alignment.centerRight, child: FilledButton.icon( onPressed: () { final state = context .findAncestorStateOfType<_AdminScreenState>(); state?._showReassignRoute(r); }, icon: const Icon(Icons.swap_horiz, size: 18), label: const Text('Reasignar unidad'), style: FilledButton.styleFrom( backgroundColor: AppTheme.primary, visualDensity: VisualDensity.compact, ), ), ), ], ], ), ); }, ); }, ); } Widget _routeStatusBadge(String status) { switch (status) { case 'en_ruta': return AppStatusBadge.amber('En ruta'); case 'completada': return AppStatusBadge.green('Completada'); case 'diferida': return AppStatusBadge.danger('Diferida'); case 'reasignada': return AppStatusBadge.amber('Reasignada'); default: return AppStatusBadge.gray('Pendiente'); } } } // ── Tab Unidades ────────────────────────────────────────────────────────────── /// Llenado estático de conductores por unidad (placeholder mientras no haya /// registros reales en la tabla `drivers`). Se usa como fallback en la /// UnitCard cuando `adminDriversProvider` no devuelve un driver asignado. const Map _staticDriversByUnit = { 101: 'Juan Pérez Hernández', 103: 'Miguel Ángel Reyes', 104: 'Carlos Eduardo Vázquez', 105: 'Roberto Sánchez Luna', 112: 'José Antonio Ramírez', 113: 'Luis Fernando Torres', }; /// Extrae el mensaje útil de un error de red, priorizando el `detail` /// devuelto por FastAPI cuando hay un 500/400. String _formatIncidentError(Object e) { if (e is DioException) { final status = e.response?.statusCode; final data = e.response?.data; String? detail; if (data is Map && data['detail'] is String) { detail = data['detail'] as String; } else if (data is String && data.isNotEmpty) { detail = data; } if (detail != null) { return status != null ? '[$status] $detail' : detail; } return 'Error de red: ${e.message ?? e.type.name}'; } return e.toString(); } class _TrucksTab extends ConsumerWidget { const _TrucksTab(); @override Widget build(BuildContext context, WidgetRef ref) { final async = ref.watch(adminUnitsProvider); final routes = ref .watch(adminRoutesProvider) .maybeWhen(data: (r) => r, orElse: () => []); final drivers = ref .watch(adminDriversProvider) .maybeWhen(data: (d) => d, orElse: () => []); return async.when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => _ErrorView( message: e.toString(), onRetry: () => ref.invalidate(adminUnitsProvider), ), data: (units) { if (units.isEmpty) { return const _EmptyView('No hay unidades registradas.'); } return ListView.separated( padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), itemCount: units.length, separatorBuilder: (_, __) => const SizedBox(height: 12), itemBuilder: (context, i) { final t = units[i]; AdminRouteModel? assignedRoute; for (final r in routes) { if (r.truckId == t.id) { assignedRoute = r; break; } } AdminDriverModel? assignedDriver; for (final d in drivers) { if (d.unitId == t.id) { assignedDriver = d; break; } } return AppCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // ── Encabezado: placa + badge estado ───────────────── Row( children: [ Expanded( child: Text( t.displayPlate, style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, ), ), ), _unitStatusBadge(t.status), ], ), const SizedBox(height: 6), // ── Detalles ────────────────────────────────────────── Text( 'ID: #${t.id}', style: const TextStyle( fontSize: 12, color: AppTheme.textSecondary, ), ), Text( 'Conductor: ${assignedDriver?.displayName ?? _staticDriversByUnit[t.id] ?? 'Sin asignar'}', style: const TextStyle(fontSize: 13), ), Text( 'Ruta: ${assignedRoute?.displayName ?? 'Sin asignar'}', style: const TextStyle( fontSize: 13, color: AppTheme.textSecondary, ), ), const SizedBox(height: 12), // ── Botones de acción ───────────────────────────────── Row( mainAxisAlignment: MainAxisAlignment.end, children: [ // Ver incidencias TextButton.icon( onPressed: () => _showIncidentsSheet(context, ref, t), icon: const Icon(Icons.warning_amber_rounded, size: 18), label: const Text('Incidencias'), style: TextButton.styleFrom( foregroundColor: Colors.orange.shade700, ), ), const SizedBox(width: 4), // Editar TextButton.icon( onPressed: () { final state = context .findAncestorStateOfType<_AdminScreenState>(); state?._showUnitForm(unit: t); }, icon: const Icon(Icons.edit_outlined, size: 18), label: const Text('Editar'), ), const SizedBox(width: 4), // Eliminar TextButton.icon( onPressed: () => _confirmAndDelete( context, tipo: 'unidad', onConfirm: () async { await ref .read(adminServiceProvider) .deleteUnit(t.id); ref.invalidate(adminUnitsProvider); ref.invalidate(adminRoutesProvider); }, ), icon: const Icon(Icons.delete_outline, size: 18), label: const Text('Eliminar'), style: TextButton.styleFrom( foregroundColor: AppTheme.danger, ), ), ], ), ], ), ); }, ); }, ); } // ── Abre el bottom sheet de incidencias ─────────────────────────────────── void _showIncidentsSheet( BuildContext context, WidgetRef ref, AdminUnitModel unit, ) { showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: AppTheme.surface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (_) => _IncidentsSheet(unit: unit), ); } Widget _unitStatusBadge(String status) { switch (status) { case 'inactive': return AppStatusBadge.gray('Inactivo'); case 'maintenance': return AppStatusBadge.amber('Mantenimiento'); default: return AppStatusBadge.green('Activo'); } } } // ── Bottom sheet de incidencias ─────────────────────────────────────────────── class _IncidentsSheet extends ConsumerStatefulWidget { const _IncidentsSheet({required this.unit}); final AdminUnitModel unit; @override ConsumerState<_IncidentsSheet> createState() => _IncidentsSheetState(); } class _IncidentsSheetState extends ConsumerState<_IncidentsSheet> { @override Widget build(BuildContext context) { final async = ref.watch(adminIncidentsByUnitProvider(widget.unit.id)); return DraggableScrollableSheet( expand: false, initialChildSize: 0.6, maxChildSize: 0.92, builder: (_, controller) => Column( children: [ // ── Handle ───────────────────────────────────────────────── const SizedBox(height: 12), Container( width: 40, height: 4, decoration: BoxDecoration( color: Colors.grey.shade300, borderRadius: BorderRadius.circular(2), ), ), const SizedBox(height: 12), // ── Header ───────────────────────────────────────────────── Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Row( children: [ const Icon( Icons.warning_amber_rounded, color: Colors.orange, size: 22, ), const SizedBox(width: 8), Expanded( child: Text( 'Incidencias — ${widget.unit.displayPlate}', style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w700, ), ), ), IconButton( icon: const Icon( Icons.add_circle_outline, color: AppTheme.primary, ), tooltip: 'Nueva incidencia', onPressed: () => _showCreateIncidentDialog(context), ), ], ), ), const Divider(height: 1), // ── Lista ─────────────────────────────────────────────────── Expanded( child: async.when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center( child: Padding( padding: const EdgeInsets.all(16), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.error_outline, color: AppTheme.danger, size: 40, ), const SizedBox(height: 8), Text( _formatIncidentError(e), textAlign: TextAlign.center, style: const TextStyle( fontSize: 13, color: AppTheme.textSecondary, ), ), const SizedBox(height: 12), ElevatedButton( onPressed: () => ref.invalidate( adminIncidentsByUnitProvider(widget.unit.id), ), child: const Text('Reintentar'), ), ], ), ), ), data: (incidents) { if (incidents.isEmpty) { return const Center( child: Padding( padding: EdgeInsets.all(24), child: Text( 'Sin incidencias registradas.', style: TextStyle(color: AppTheme.textSecondary), ), ), ); } return ListView.separated( controller: controller, padding: const EdgeInsets.fromLTRB(16, 12, 16, 32), itemCount: incidents.length, separatorBuilder: (_, __) => const SizedBox(height: 10), itemBuilder: (_, i) => _IncidentCard(incident: incidents[i]), ); }, ), ), ], ), ); } Future _showCreateIncidentDialog(BuildContext context) async { String type = 'otro'; final desc = TextEditingController(); final formKey = GlobalKey(); final saved = await showDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, setStateDialog) => AlertDialog( backgroundColor: AppTheme.surface, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppTheme.radiusLg), ), title: const Text('Nueva incidencia'), content: Form( key: formKey, child: Column( mainAxisSize: MainAxisSize.min, children: [ DropdownButtonFormField( initialValue: type, decoration: InputDecoration( labelText: 'Categoría', border: OutlineInputBorder( borderRadius: BorderRadius.circular(AppTheme.radiusMd), ), ), items: const [ DropdownMenuItem( value: 'derrame', child: Text('💧 Derrame'), ), DropdownMenuItem( value: 'dano_propiedad', child: Text('💥 Daño a propiedad'), ), DropdownMenuItem( value: 'conducta', child: Text('😠 Conducta'), ), DropdownMenuItem( value: 'no_recoleccion', child: Text('🗑 No recolección'), ), DropdownMenuItem(value: 'otro', child: Text('📋 Otro')), ], onChanged: (v) { if (v != null) setStateDialog(() => type = v); }, ), const SizedBox(height: 10), TextFormField( controller: desc, maxLines: 3, decoration: InputDecoration( labelText: 'Descripción', helperText: 'Mínimo 3 caracteres', border: OutlineInputBorder( borderRadius: BorderRadius.circular(AppTheme.radiusMd), ), ), validator: (v) { final t = (v ?? '').trim(); if (t.length < 3) return 'Describe brevemente lo ocurrido'; return null; }, ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancelar'), ), ElevatedButton( onPressed: () async { if (!(formKey.currentState?.validate() ?? false)) return; try { await ref .read(adminServiceProvider) .createIncident( unitId: widget.unit.id, type: type, description: desc.text.trim(), ); if (ctx.mounted) Navigator.pop(ctx, true); } catch (e) { if (ctx.mounted) { ScaffoldMessenger.of(ctx).showSnackBar( SnackBar( content: Text('Error: $e'), backgroundColor: AppTheme.danger, ), ); } } }, child: const Text('Guardar'), ), ], ), ), ); if (saved == true) { ref.invalidate(adminIncidentsByUnitProvider(widget.unit.id)); } } } // ── Tarjeta individual de incidencia ────────────────────────────────────────── class _IncidentCard extends StatelessWidget { const _IncidentCard({required this.incident}); final AdminIncidentModel incident; @override Widget build(BuildContext context) { return AppCard( child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ _typeIcon(incident.type), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // ── Tipo + fecha ────────────────────────────────────── Row( children: [ _typeBadge(incident.type), const Spacer(), Text( _formatDate(incident.createdAt), style: const TextStyle( fontSize: 11, color: AppTheme.textSecondary, ), ), ], ), // ── Conductor ───────────────────────────────────────── if ((incident.driverName ?? _staticDriversByUnit[incident.unitId]) != null) ...[ const SizedBox(height: 6), Row( children: [ const Icon( Icons.person_outline, size: 14, color: AppTheme.textSecondary, ), const SizedBox(width: 4), Expanded( child: Text( incident.driverName ?? _staticDriversByUnit[incident.unitId]!, style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w500, ), overflow: TextOverflow.ellipsis, ), ), ], ), ], // ── Ruta ───────────────────────────────────────────── if (incident.routeId != null) ...[ const SizedBox(height: 2), Text( 'Ruta: ${incident.routeId}', style: const TextStyle( fontSize: 12, color: AppTheme.textSecondary, ), ), ], // ── Descripción ─────────────────────────────────────── if (incident.description != null && incident.description!.isNotEmpty) ...[ const SizedBox(height: 6), Text( incident.description!, style: const TextStyle(fontSize: 13), ), ], ], ), ), ], ), ); } Widget _typeIcon(String type) { IconData icon; Color color; switch (type) { case 'derrame': icon = Icons.water_drop_outlined; color = Colors.blue; break; case 'dano_propiedad': icon = Icons.report_gmailerrorred_outlined; color = AppTheme.danger; break; case 'conducta': icon = Icons.sentiment_very_dissatisfied_outlined; color = Colors.orange; break; case 'no_recoleccion': icon = Icons.delete_forever_outlined; color = Colors.deepOrange; break; default: icon = Icons.info_outline; color = AppTheme.textSecondary; } return Icon(icon, color: color, size: 22); } Widget _typeBadge(String type) { switch (type) { case 'derrame': return AppStatusBadge.amber('Derrame'); case 'dano_propiedad': return AppStatusBadge.danger('Daño'); case 'conducta': return AppStatusBadge.amber('Conducta'); case 'no_recoleccion': return AppStatusBadge.danger('No recolección'); default: return AppStatusBadge.gray('Otro'); } } String _formatDate(DateTime dt) { final d = dt.toLocal(); final day = d.day.toString().padLeft(2, '0'); final month = d.month.toString().padLeft(2, '0'); final hour = d.hour.toString().padLeft(2, '0'); final minute = d.minute.toString().padLeft(2, '0'); return '$day/$month/${d.year} $hour:$minute'; } } // ── Shared widgets ──────────────────────────────────────────────────────────── class _EmptyView extends StatelessWidget { const _EmptyView(this.message); final String message; @override Widget build(BuildContext context) => Center( child: Padding( padding: const EdgeInsets.all(24), child: Text( message, textAlign: TextAlign.center, style: const TextStyle(fontSize: 15, color: AppTheme.textSecondary), ), ), ); } class _ErrorView extends StatelessWidget { const _ErrorView({required this.message, required this.onRetry}); final String message; final VoidCallback onRetry; @override Widget build(BuildContext context) => Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.error_outline, color: AppTheme.danger, size: 48), const SizedBox(height: 12), Text( message, textAlign: TextAlign.center, style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary), ), const SizedBox(height: 16), ElevatedButton.icon( onPressed: onRetry, icon: const Icon(Icons.refresh), label: const Text('Reintentar'), ), ], ), ), ); } void _confirmAndDelete( BuildContext context, { required String tipo, required Future Function() onConfirm, }) { showDialog( context: context, builder: (ctx) => AlertDialog( backgroundColor: AppTheme.surface, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppTheme.radiusLg), ), title: Text('Eliminar $tipo'), content: Text('¿Deseas eliminar este $tipo?'), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), style: TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), child: const Text('Cancelar'), ), TextButton( onPressed: () async { Navigator.pop(ctx); try { await onConfirm(); if (context.mounted) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('$tipo eliminado'))); } } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Error: $e'), backgroundColor: AppTheme.danger, ), ); } } }, style: TextButton.styleFrom(foregroundColor: AppTheme.danger), child: const Text('Eliminar'), ), ], ), ); } // EOF