simulacion de estados y flujo de notificacion, modificacion de estilos en todas las vistas
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
67
recolecta_app/lib/features/admin/models/admin_incident.dart
Normal file
67
recolecta_app/lib/features/admin/models/admin_incident.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user