simulacion de estados y flujo de notificacion, modificacion de estilos en todas las vistas

This commit is contained in:
shinra32
2026-05-23 07:08:49 -06:00
parent ca076607c7
commit 92f570294a
43 changed files with 4335 additions and 2035 deletions

View File

@@ -7,6 +7,7 @@ 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';
@@ -67,6 +68,23 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
}
}
Future<void> _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(
@@ -74,6 +92,11 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
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),
@@ -101,7 +124,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
tabs: const [
Tab(text: 'Usuarios'),
Tab(text: 'Rutas'),
Tab(text: 'Camiones'),
Tab(text: 'Unidades'),
],
),
),
@@ -118,7 +141,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
? 'Nuevo usuario'
: _activeTab == 1
? 'Nueva ruta'
: 'Nuevo camión',
: 'Nueva unidad',
),
),
);
@@ -372,7 +395,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
DropdownButtonFormField<int?>(
initialValue: truckId,
decoration: InputDecoration(
labelText: 'Camión asignado',
labelText: 'Unidad asignada',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(
AppTheme.radiusMd,
@@ -445,7 +468,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
}
}
// ── Formulario camión (unit) ────────────────────────────────────────────────
// ── Formulario unidad ───────────────────────────────────────────────────────
Future<void> _showUnitForm({AdminUnitModel? unit}) async {
final isEdit = unit != null;
final idCtrl = TextEditingController(text: unit?.id.toString() ?? '');
@@ -463,7 +486,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
),
title: Text(isEdit ? 'Editar camión' : 'Nuevo camión'),
title: Text(isEdit ? 'Editar unidad' : 'Nueva unidad'),
content: Form(
key: formKey,
child: SingleChildScrollView(
@@ -561,7 +584,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
if (saved == true) {
ref.invalidate(adminUnitsProvider);
ref.invalidate(adminRoutesProvider);
_snack(isEdit ? 'Camión actualizado' : 'Camión creado');
_snack(isEdit ? 'Unidad actualizada' : 'Unidad creada');
}
}
@@ -774,7 +797,7 @@ class _RoutesTab extends ConsumerWidget {
style: const TextStyle(fontSize: 13),
),
Text(
'Camión: ${unit?.displayPlate ?? (r.truckId == null ? 'Sin asignar' : '#${r.truckId}')}',
'Unidad: ${unit?.displayPlate ?? (r.truckId == null ? 'Sin asignar' : '#${r.truckId}')}',
style: const TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
@@ -845,6 +868,7 @@ class _RoutesTab extends ConsumerWidget {
}
}
// ── Tab Unidades ──────────────────────────────────────────────────────────────
class _TrucksTab extends ConsumerWidget {
const _TrucksTab();
@@ -866,7 +890,7 @@ class _TrucksTab extends ConsumerWidget {
),
data: (units) {
if (units.isEmpty) {
return const _EmptyView('No hay camiones registrados.');
return const _EmptyView('No hay unidades registradas.');
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
@@ -892,6 +916,7 @@ class _TrucksTab extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Encabezado: placa + badge estado ─────────────────
Row(
children: [
Expanded(
@@ -907,6 +932,7 @@ class _TrucksTab extends ConsumerWidget {
],
),
const SizedBox(height: 6),
// ── Detalles ──────────────────────────────────────────
Text(
'ID: #${t.id}',
style: const TextStyle(
@@ -926,9 +952,21 @@ class _TrucksTab extends ConsumerWidget {
),
),
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
@@ -938,11 +976,12 @@ class _TrucksTab extends ConsumerWidget {
icon: const Icon(Icons.edit_outlined, size: 18),
label: const Text('Editar'),
),
const SizedBox(width: 8),
const SizedBox(width: 4),
// Eliminar
TextButton.icon(
onPressed: () => _confirmAndDelete(
context,
tipo: 'camión',
tipo: 'unidad',
onConfirm: () async {
await ref
.read(adminServiceProvider)
@@ -968,6 +1007,23 @@ class _TrucksTab extends ConsumerWidget {
);
}
// ── 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':
@@ -980,6 +1036,374 @@ class _TrucksTab extends ConsumerWidget {
}
}
// ── 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: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.error_outline,
color: AppTheme.danger,
size: 40,
),
const SizedBox(height: 8),
Text(
e.toString(),
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<void> _showCreateIncidentDialog(BuildContext context) async {
String type = 'otro';
final desc = TextEditingController();
final formKey = GlobalKey<FormState>();
final saved = await showDialog<bool>(
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<String>(
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 != 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!,
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);
@@ -1079,4 +1503,3 @@ void _confirmAndDelete(
}
// EOF

View File

@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/api_client.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';
@@ -188,4 +189,42 @@ class AdminService {
Future<void> deleteDriver(String id) async {
await _dio.delete<void>('/admin/drivers/$id');
}
// ── Incidents ────────────────────────────────────────────────────────────────
Future<List<AdminIncidentModel>> listIncidentsByUnit(int unitId) async {
final res = await _dio.get<List<dynamic>>('/admin/units/$unitId/incidents');
return (res.data ?? [])
.whereType<Map>()
.map((e) => AdminIncidentModel.fromJson(Map<String, dynamic>.from(e)))
.toList();
}
Future<AdminIncidentModel> createIncident({
required int unitId,
required String type,
String? description,
}) async {
final res = await _dio.post<Map<String, dynamic>>(
'/admin/units/$unitId/incidents',
data: {
'unit_id': unitId,
'type': type,
if (description != null && description.isNotEmpty)
'description': description,
},
);
return AdminIncidentModel.fromJson(res.data!);
}
// ── Simulación ──────────────────────────────────────────────────────────────
/// Avanza una vez la simulación (`positionId += 1` en todas las rutas) y
/// dispara los push FCM correspondientes. Devuelve los eventos disparados.
Future<List<Map<String, dynamic>>> simulationTick() async {
final res = await _dio.post<Map<String, dynamic>>('/simulation/tick');
final events = (res.data?['events'] as List?) ?? const [];
return events
.whereType<Map>()
.map((e) => Map<String, dynamic>.from(e))
.toList();
}
}

View File

@@ -0,0 +1,67 @@
// lib/features/admin/models/admin_incident.dart
class AdminIncidentModel {
final String id;
final int unitId;
final String? routeId;
// Mapea a `incidents.category` en la base de datos.
final String type;
final String? description;
final String? driverName;
final String status; // open | in_review | resolved
final String? photoUrl;
final DateTime createdAt;
const AdminIncidentModel({
required this.id,
required this.unitId,
this.routeId,
required this.type,
this.description,
this.driverName,
this.status = 'open',
this.photoUrl,
required this.createdAt,
});
factory AdminIncidentModel.fromJson(Map<String, dynamic> json) =>
AdminIncidentModel(
// El id en DB es BIGSERIAL; el backend lo serializa como string,
// pero por defensa aceptamos number también.
id: json['id'].toString(),
unitId: (json['unit_id'] as num).toInt(),
routeId: json['route_id'] as String?,
type: (json['type'] as String?) ?? 'otro',
description: json['description'] as String?,
driverName: json['driver_name'] as String?,
status: (json['status'] as String?) ?? 'open',
photoUrl: json['photo_url'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
);
String get typeLabel {
switch (type) {
case 'derrame':
return 'Derrame';
case 'dano_propiedad':
return 'Daño a propiedad';
case 'conducta':
return 'Conducta';
case 'no_recoleccion':
return 'No recolección';
default:
return 'Otro';
}
}
String get statusLabel {
switch (status) {
case 'in_review':
return 'En revisión';
case 'resolved':
return 'Resuelta';
default:
return 'Abierta';
}
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.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';
@@ -21,3 +22,8 @@ final adminUnitsProvider = FutureProvider<List<AdminUnitModel>>((ref) {
final adminDriversProvider = FutureProvider<List<AdminDriverModel>>((ref) {
return ref.read(adminServiceProvider).listDrivers();
});
final adminIncidentsByUnitProvider =
FutureProvider.family<List<AdminIncidentModel>, int>((ref, unitId) {
return ref.read(adminServiceProvider).listIncidentsByUnit(unitId);
});