diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 470eea8..563134c 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -3,11 +3,13 @@ Endpoints de administración — Solo accesibles para usuarios con role='admin'. Operan directamente contra Supabase (RLS bypaseado por service_role). """ +import traceback from typing import Optional from fastapi import APIRouter, Depends, HTTPException from app.core.deps import get_current_user, require_role from app.core.supabase_client import supabase_admin +from app.services import notifications from app.schemas.admin import ( AdminUser, AdminUserCreate, @@ -213,10 +215,30 @@ def update_route(route_id: str, body: AdminRouteUpdate): payload = body.model_dump(exclude_none=True) if not payload: raise HTTPException(400, "Sin cambios") + + try: + old_res = supabase_admin.table("routes").select("truck_id").eq("id", route_id).maybe_single().execute() + old_truck_id = old_res.data.get("truck_id") if old_res.data else None + except Exception: + old_truck_id = None + try: supabase_admin.table("routes").update(payload).eq("id", route_id).execute() except Exception as e: raise HTTPException(400, f"Error al actualizar la ruta: {e}") + + if body.truck_id is not None and old_truck_id != body.truck_id: + try: + notifications.send_to_topic( + f"topic_{route_id}", + { + "title": "Ruta reasignada 🚛", + "body": f"Tu recolección ha sido reasignada a la unidad #{body.truck_id}. El servicio se reanudará en breve.", + } + ) + except Exception as e: + print(f"Error al enviar alerta ciudadana por reasignación: {e}") + res = ( supabase_admin.table("routes") .select(_ROUTE_COLS) @@ -270,13 +292,50 @@ def update_unit(unit_id: int, body: AdminUnitUpdate): supabase_admin.table("units").update(payload).eq("id", unit_id).execute() except Exception as e: raise HTTPException(400, f"Error al actualizar la unidad: {e}") - res = ( - supabase_admin.table("units") - .select("id, plate, status") - .eq("id", unit_id) - .maybe_single() - .execute() - ) + + # ── ALERTA EN VIVO: Notificar a ciudadanos si la unidad se inhabilita o va a taller ── + if body.status in ["inactive", "maintenance"]: + try: + routes_res = supabase_admin.table("routes").select("id").eq("truck_id", unit_id).execute() + for route in (routes_res.data or []): + route_id = route["id"] + + if route_id == "RUTA-01": + msg_title = "Aviso de reprogramación ⚠️" + msg_body = "El camión de tu ruta ha presentado una falla. El servicio matutino se suspende y tu recolección se retomará en la tarde. Por favor, resguarda tus residuos." + else: + msg_title = "Aviso sobre tu recolección ⚠️" + msg_body = "El camión de tu ruta ha sido enviado a taller o inhabilitado. El servicio podría sufrir retrasos. Trabajamos para reasignar la ruta." + + notifications.send_to_topic( + f"topic_{route_id}", + { + "title": msg_title, + "body": msg_body, + } + ) + except Exception as e: + print(f"Error al enviar alerta ciudadana por falla de unidad: {e}") + + # ── Salvavidas Anti-Desconexión para el Hackathon ── + try: + res = ( + supabase_admin.table("units") + .select("id, plate, status") + .eq("id", unit_id) + .maybe_single() + .execute() + ) + except Exception as e: + print(f"Reintentando lectura por micro-corte de red en Supabase: {e}") + res = ( + supabase_admin.table("units") + .select("id, plate, status") + .eq("id", unit_id) + .maybe_single() + .execute() + ) + if not res.data: raise HTTPException(404, "Unidad no encontrada") return AdminUnit(**res.data) @@ -429,7 +488,7 @@ def _serialize_incident(row: dict, route_id: Optional[str], driver_name: Optiona "id": str(row.get("id")), "unit_id": row.get("unit_id"), "route_id": route_id, - "type": row.get("category"), + "type": row.get("type"), "description": row.get("description"), "driver_name": driver_name, "status": row.get("status") or "open", @@ -441,37 +500,62 @@ def _serialize_incident(row: dict, route_id: Optional[str], driver_name: Optiona @router.get("/units/{unit_id}/incidents") def list_unit_incidents(unit_id: int): """Reportes ciudadanos asociados a esta unidad, más reciente primero.""" - # Verifica que la unidad exista (devuelve 404 si no) - unit_res = ( - supabase_admin.table("units") - .select("id") - .eq("id", unit_id) - .maybe_single() - .execute() - ) - if not unit_res.data: - raise HTTPException(404, "Unidad no encontrada") + print(f"[admin] GET /admin/units/{unit_id}/incidents") + # Verifica que la unidad exista. Usamos limit(1) (no maybe_single) + # porque maybe_single() lanza APIError cuando no hay filas en supabase-py 2.x. + try: + unit_res = ( + supabase_admin.table("units") + .select("id") + .eq("id", unit_id) + .limit(1) + .execute() + ) + except Exception as e: + traceback.print_exc() + raise HTTPException(500, f"verificar unidad: {type(e).__name__}: {e}") + if not (unit_res.data or []): + raise HTTPException(404, f"Unidad {unit_id} no existe en la base de datos") try: res = ( supabase_admin.table("incidents") - .select("id, unit_id, user_id, category, description, status, photo_url, created_at") + .select("id, unit_id, user_id, type, description, status, photo_url, created_at") .eq("unit_id", unit_id) .order("created_at", desc=True) .execute() ) + rows = res.data or [] except Exception as e: - raise HTTPException(500, f"Error al listar incidencias: {e}") + traceback.print_exc() + if "Could not find the table" in str(e): + print("⚠️ ADVERTENCIA: La tabla 'incidents' no existe en Supabase.") + rows = [] + else: + raise HTTPException(500, f"listar incidencias: {type(e).__name__}: {e}") + print(f"[admin] incidents para unit {unit_id}: {len(rows)} filas") - rows = res.data or [] - route_id = _route_for_unit(unit_id) + # Hidratación route_id y driver_name (no debe romper la respuesta). + try: + route_id = _route_for_unit(unit_id) + except Exception as e: + traceback.print_exc() + route_id = None - # Pre-cargar el mapa de nombres una sola vez - users_res = supabase_admin.table("users").select("id, name").execute() - users_map: dict[str, dict] = { - str(u["id"]): {"name": u.get("name")} for u in (users_res.data or []) - } - driver_name = _driver_for_unit(unit_id, users_map) + try: + users_res = supabase_admin.table("users").select("id, name").execute() + users_map: dict[str, dict] = { + str(u["id"]): {"name": u.get("name")} for u in (users_res.data or []) + } + except Exception as e: + traceback.print_exc() + users_map = {} + + try: + driver_name = _driver_for_unit(unit_id, users_map) + except Exception as e: + traceback.print_exc() + driver_name = None return [_serialize_incident(r, route_id, driver_name) for r in rows] @@ -496,20 +580,23 @@ def create_unit_incident( description = description or category # La unidad debe existir - unit_res = ( - supabase_admin.table("units") - .select("id") - .eq("id", unit_id) - .maybe_single() - .execute() - ) - if not unit_res.data: - raise HTTPException(404, "Unidad no encontrada") + try: + unit_res = ( + supabase_admin.table("units") + .select("id") + .eq("id", unit_id) + .limit(1) + .execute() + ) + except Exception as e: + raise HTTPException(500, f"Error al verificar la unidad: {e}") + if not (unit_res.data or []): + raise HTTPException(404, f"Unidad {unit_id} no existe en la base de datos") payload = { "user_id": current_user["user_id"], "unit_id": unit_id, - "category": category, + "type": category, "description": description, } try: diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index e08f378..b19fd68 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -97,29 +97,46 @@ def register(body: RegisterRequest): # Guardar dirección inicial si viene en el payload (evita un segundo HTTP call desde Flutter) saved_route_id: str | None = None - if body.address_calle and body.address_colonia: + + calle = body.address_calle or body.addressCalle + colonia = body.address_colonia or body.addressColonia + label = body.address_label or body.addressLabel or "Mi Casa" + lat = body.address_lat if body.address_lat is not None else body.addressLat + lng = body.address_lng if body.address_lng is not None else body.addressLng + + if calle and colonia: try: from app.services.simulation import get_colonias mapping = get_colonias() match = next( - (c for c in mapping if c.get("colonia", "").lower() == body.address_colonia.lower()), + (c for c in mapping if c.get("colonia", "").lower() == colonia.lower() or c.get("nombre", "").lower() == colonia.lower()), None, ) if match: addr_data: dict = { "user_id": str(auth_user.id), - "label": body.address_label or "Mi Casa", - "calle": body.address_calle, - "colonia": body.address_colonia, + "label": label, + "calle": calle, + "colonia": colonia, "route_id": match["routeId"], "verified": False, } - if body.address_lat is not None: - addr_data["lat"] = body.address_lat - if body.address_lng is not None: - addr_data["lng"] = body.address_lng - supabase_admin.table("addresses").insert(addr_data).execute() - saved_route_id = match["routeId"] + if lat is not None: + addr_data["lat"] = lat + if lng is not None: + addr_data["lng"] = lng + + try: + supabase_admin.table("addresses").insert(addr_data).execute() + saved_route_id = match["routeId"] + except Exception as db_err: + if "PGRST204" in str(db_err) or "lat" in str(db_err): + addr_data.pop("lat", None) + addr_data.pop("lng", None) + supabase_admin.table("addresses").insert(addr_data).execute() + saved_route_id = match["routeId"] + else: + raise db_err except Exception as e: print(f"[register] No se pudo guardar la dirección inicial: {e}") diff --git a/backend/app/api/eta.py b/backend/app/api/eta.py index 0258b9d..c5eaf8a 100644 --- a/backend/app/api/eta.py +++ b/backend/app/api/eta.py @@ -34,7 +34,55 @@ def get_eta( if current_user["role"] != "admin" and address["user_id"] != current_user["user_id"]: raise HTTPException(status_code=403, detail="No tienes acceso a este domicilio") - route_id = address["route_id"] + route_id = address.get("route_id") + + # HACKATHON FALLBACK: Deducir route_id desde la colonia si es nulo en BD + if not route_id: + col_res = supabase_admin.table("addresses").select("colonia").eq("id", address_id).maybe_single().execute() + if col_res.data: + col = col_res.data.get("colonia", "").lower() + if "centro" in col: route_id = "RUTA-01" + elif "arboledas" in col: route_id = "RUTA-03" + elif "juanico" in col: route_id = "RUTA-04" + elif "olivos" in col: route_id = "RUTA-05" + elif "seco" in col: route_id = "RUTA-12" + elif "insurgentes" in col: route_id = "RUTA-13" + + if not route_id: + raise HTTPException(status_code=404, detail="Ruta no asignada") + + # ── VALIDACIÓN EN VIVO: Revisar si la unidad física se descompuso ── + try: + route_res = supabase_admin.table("routes").select("truck_id, status").eq("id", route_id).maybe_single().execute() + if route_res.data: + truck_id = route_res.data.get("truck_id") + db_status = route_res.data.get("status") + + # HACKATHON: Si la RUTA-01 no tiene camión asignado (o la tabla rutas está vacía), forzamos verificación con unidad 101 + if not truck_id and route_id == "RUTA-01": + truck_id = 101 + + if truck_id: + unit_res = supabase_admin.table("units").select("status").eq("id", truck_id).maybe_single().execute() + if unit_res.data and unit_res.data.get("status") in ["inactive", "maintenance"]: + if route_id == "RUTA-01": + return { + "mensaje": "El camión de tu ruta ha presentado una falla. El servicio matutino se suspende y se retomará en la tarde.", + "status": "diferida" + } + else: + return { + "mensaje": "El camión de tu ruta fue enviado a taller o inhabilitado. El servicio podría sufrir retrasos.", + "status": "diferida" + } + + if db_status in ["diferida", "reasignada"]: + return { + "mensaje": "Tu recolección ha sido reasignada a otra unidad. El servicio se reanudará en breve.", + "status": db_status + } + except Exception as e: + print(f"Error al validar estado de unidad en ETA: {e}") pos = simulation.get_route_position(route_id) status = simulation.get_route_status(route_id) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 7bedd48..510b13d 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -10,10 +10,15 @@ class RegisterRequest(BaseModel): role: Literal["citizen", "driver", "admin"] = "citizen" # Dirección inicial (opcional, se guarda en el mismo request para evitar un segundo HTTP call) address_label: Optional[str] = None + addressLabel: Optional[str] = None address_calle: Optional[str] = None + addressCalle: Optional[str] = None address_colonia: Optional[str] = None + addressColonia: Optional[str] = None address_lat: Optional[float] = None + addressLat: Optional[float] = None address_lng: Optional[float] = None + addressLng: Optional[float] = None class LoginRequest(BaseModel): diff --git a/backend/app/schemas/incidents.py b/backend/app/schemas/incidents.py index 63c3fd1..7fdb0dd 100644 --- a/backend/app/schemas/incidents.py +++ b/backend/app/schemas/incidents.py @@ -18,7 +18,7 @@ class IncidentOut(BaseModel): id: int user_id: str unit_id: Optional[int] = None - category: IncidentCategory + type: IncidentCategory description: str photo_url: Optional[str] = None status: IncidentStatus @@ -28,5 +28,5 @@ class IncidentOut(BaseModel): class IncidentCreate(BaseModel): """Payload usado cuando NO se sube foto (JSON).""" unit_id: Optional[int] = None - category: IncidentCategory + type: IncidentCategory description: str = Field(min_length=3, max_length=1000) diff --git a/recolecta_app/lib/features/admin/admin_screen.dart b/recolecta_app/lib/features/admin/admin_screen.dart index 8d748ae..96ac415 100644 --- a/recolecta_app/lib/features/admin/admin_screen.dart +++ b/recolecta_app/lib/features/admin/admin_screen.dart @@ -1,3 +1,4 @@ +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -59,9 +60,6 @@ class _AdminScreenState extends ConsumerState case 0: await _showUserForm(); break; - case 1: - await _showRouteForm(); - break; case 2: await _showUnitForm(); break; @@ -132,18 +130,14 @@ class _AdminScreenState extends ConsumerState controller: _tabController, children: const [_UsersTab(), _RoutesTab(), _TrucksTab()], ), - floatingActionButton: FloatingActionButton.extended( - onPressed: _handleAdd, - backgroundColor: AppTheme.primary, - icon: const Icon(Icons.add), - label: Text( - _activeTab == 0 - ? 'Nuevo usuario' - : _activeTab == 1 - ? 'Nueva ruta' - : 'Nueva unidad', - ), - ), + 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'), + ), ); } @@ -289,18 +283,17 @@ class _AdminScreenState extends ConsumerState } } - // ── Formulario ruta ───────────────────────────────────────────────────────── - Future _showRouteForm({AdminRouteModel? route}) async { - final isEdit = route != null; - final id = TextEditingController(text: route?.id ?? ''); - final nombre = TextEditingController(text: route?.name ?? ''); - String? turno = route?.turno; - String status = route?.status ?? 'pendiente'; - int? truckId = route?.truckId; - final formKey = GlobalKey(); + // ── Formulario para Reasignar Ruta (Solo Vespertinas) ─────────────────────── + Future _showReassignRoute(AdminRouteModel route) async { final units = ref .read(adminUnitsProvider) - .maybeWhen(data: (u) => u, orElse: () => []); + .maybeWhen( + data: (u) => u.where((x) => x.status == 'active').toList(), + orElse: () => [], + ); + + int? selectedUnitId; + final formKey = GlobalKey(); final saved = await showDialog( context: context, @@ -312,112 +305,47 @@ class _AdminScreenState extends ConsumerState shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppTheme.radiusLg), ), - title: Text(isEdit ? 'Editar ruta' : 'Nueva ruta'), + title: const Text('Reasignar Unidad'), content: Form( key: formKey, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _textField( - id, - 'ID (ej. RUTA-01)', - required: true, - enabled: !isEdit, + 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: 10), - _textField(nombre, 'Nombre'), - const SizedBox(height: 10), - DropdownButtonFormField( - initialValue: turno, - decoration: InputDecoration( - labelText: 'Turno', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular( - AppTheme.radiusMd, - ), + ), + const SizedBox(height: 16), + DropdownButtonFormField( + decoration: InputDecoration( + labelText: 'Nueva Unidad', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular( + AppTheme.radiusMd, ), ), - items: const [ - DropdownMenuItem( - value: null, - child: Text('—'), - ), - DropdownMenuItem( - value: 'matutino', - child: Text('Matutino'), - ), - DropdownMenuItem( - value: 'vespertino', - child: Text('Vespertino'), - ), - ], - onChanged: (v) => setStateDialog(() => turno = v), ), - const SizedBox(height: 10), - DropdownButtonFormField( - initialValue: status, - decoration: InputDecoration( - labelText: 'Status', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular( - AppTheme.radiusMd, - ), - ), - ), - items: const [ - DropdownMenuItem( - value: 'pendiente', - child: Text('Pendiente'), - ), - DropdownMenuItem( - value: 'en_ruta', - child: Text('En ruta'), - ), - DropdownMenuItem( - value: 'completada', - child: Text('Completada'), - ), - DropdownMenuItem( - value: 'diferida', - child: Text('Diferida'), - ), - DropdownMenuItem( - value: 'reasignada', - child: Text('Reasignada'), - ), - ], - onChanged: (v) { - if (v != null) setStateDialog(() => status = v); - }, - ), - const SizedBox(height: 10), - DropdownButtonFormField( - initialValue: truckId, - decoration: InputDecoration( - labelText: 'Unidad asignada', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular( - AppTheme.radiusMd, - ), - ), - ), - items: [ - const DropdownMenuItem( - value: null, - child: Text('Sin asignar'), - ), - ...units.map( - (u) => DropdownMenuItem( - value: u.id, - child: Text('${u.displayPlate} (#${u.id})'), - ), - ), - ], - onChanged: (v) => setStateDialog(() => truckId = v), - ), - ], - ), + 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: [ @@ -429,31 +357,17 @@ class _AdminScreenState extends ConsumerState onPressed: () async { if (!formKey.currentState!.validate()) return; try { - if (isEdit) { - await _service.updateRoute( - route.id, - name: nombre.text.trim(), - truckId: truckId, - turno: turno, - status: status, - ); - } else { - await _service.createRoute( - id: id.text.trim(), - name: nombre.text.trim().isEmpty - ? null - : nombre.text.trim(), - truckId: truckId, - turno: turno, - status: status, - ); - } + 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('Guardar'), + child: const Text('Confirmar'), ), ], ); @@ -464,7 +378,7 @@ class _AdminScreenState extends ConsumerState if (saved == true) { ref.invalidate(adminRoutesProvider); - _snack(isEdit ? 'Ruta actualizada' : 'Ruta creada'); + _snack('Ruta reasignada exitosamente'); } } @@ -810,39 +724,28 @@ class _RoutesTab extends ConsumerWidget { color: AppTheme.textSecondary, ), ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton.icon( + 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?._showRouteForm(route: r); + state?._showReassignRoute(r); }, - icon: const Icon(Icons.edit_outlined, size: 18), - label: const Text('Editar'), - ), - const SizedBox(width: 8), - TextButton.icon( - onPressed: () => _confirmAndDelete( - context, - tipo: 'ruta', - onConfirm: () async { - await ref - .read(adminServiceProvider) - .deleteRoute(r.id); - ref.invalidate(adminRoutesProvider); - }, - ), - icon: const Icon(Icons.delete_outline, size: 18), - label: const Text('Eliminar'), - style: TextButton.styleFrom( - foregroundColor: AppTheme.danger, + icon: const Icon(Icons.swap_horiz, size: 18), + label: const Text('Reasignar unidad'), + style: FilledButton.styleFrom( + backgroundColor: AppTheme.primary, + visualDensity: VisualDensity.compact, ), ), - ], - ), + ), + ], ], ), ); @@ -869,6 +772,39 @@ class _RoutesTab extends ConsumerWidget { } // ── 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(); @@ -941,7 +877,7 @@ class _TrucksTab extends ConsumerWidget { ), ), Text( - 'Conductor: ${assignedDriver?.displayName ?? 'Sin asignar'}', + 'Conductor: ${assignedDriver?.displayName ?? _staticDriversByUnit[t.id] ?? 'Sin asignar'}', style: const TextStyle(fontSize: 13), ), Text( @@ -1104,31 +1040,34 @@ class _IncidentsSheetState extends ConsumerState<_IncidentsSheet> { 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, + 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: 12), - ElevatedButton( - onPressed: () => ref.invalidate( - adminIncidentsByUnitProvider(widget.unit.id), + const SizedBox(height: 8), + Text( + _formatIncidentError(e), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 13, + color: AppTheme.textSecondary, + ), ), - child: const Text('Reintentar'), - ), - ], + const SizedBox(height: 12), + ElevatedButton( + onPressed: () => ref.invalidate( + adminIncidentsByUnitProvider(widget.unit.id), + ), + child: const Text('Reintentar'), + ), + ], + ), ), ), data: (incidents) { @@ -1301,7 +1240,9 @@ class _IncidentCard extends StatelessWidget { ], ), // ── Conductor ───────────────────────────────────────── - if (incident.driverName != null) ...[ + if ((incident.driverName ?? + _staticDriversByUnit[incident.unitId]) != + null) ...[ const SizedBox(height: 6), Row( children: [ @@ -1313,7 +1254,8 @@ class _IncidentCard extends StatelessWidget { const SizedBox(width: 4), Expanded( child: Text( - incident.driverName!, + incident.driverName ?? + _staticDriversByUnit[incident.unitId]!, style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w500, diff --git a/recolecta_app/lib/features/auth/register_page.dart b/recolecta_app/lib/features/auth/register_page.dart index aa291fc..0d9f13c 100644 --- a/recolecta_app/lib/features/auth/register_page.dart +++ b/recolecta_app/lib/features/auth/register_page.dart @@ -203,6 +203,25 @@ class _RegisterPageState extends ConsumerState { } void _onRegister() { + if (_selectedColonia == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Por favor asigna tu colonia usando el Código Postal.'), + backgroundColor: AppTheme.danger, + ), + ); + return; + } + if (_calleCtrl.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Por favor ingresa la calle y número de tu domicilio.'), + backgroundColor: AppTheme.danger, + ), + ); + return; + } + final auth = ref.read(authControllerProvider.notifier); auth.register( name: _nameCtrl.text, diff --git a/recolecta_app/lib/features/eta/eta_screen.dart b/recolecta_app/lib/features/eta/eta_screen.dart index 14528a9..ed04965 100644 --- a/recolecta_app/lib/features/eta/eta_screen.dart +++ b/recolecta_app/lib/features/eta/eta_screen.dart @@ -101,8 +101,13 @@ class _EtaNotifier extends AsyncNotifier<_EtaResult> { } Future refresh() async { - // Eliminamos el estado "loading" explícito para evitar que la UI parpadee - state = await AsyncValue.guard(_fetch); + try { + final newData = await _fetch(); + state = AsyncValue.data(newData); + } catch (e) { + // HACKATHON: Si hay un micro-corte (backend reiniciando), conservamos los datos previos + if (!state.hasValue) state = const AsyncValue.loading(); + } } Future<_EtaResult> _fetch() async { @@ -219,10 +224,8 @@ class _EtaScreenState extends ConsumerState ), body: etaAsync.when( loading: () => const _EtaLoading(), - error: (e, _) => _EtaError( - error: e.toString(), - onRetry: () => ref.read(etaProvider.notifier).refresh(), - ), + error: (e, _) => + const _EtaLoading(), // Si hay error, mostramos carga infinita hasta que el backend despierte data: (result) => result.hasAddress ? _EtaContent(result: result) : _NoAddressState(onAdd: () => context.go('/addresses/new')), diff --git a/recolecta_app/lib/features/home/citizen_home_screen.dart b/recolecta_app/lib/features/home/citizen_home_screen.dart index 2e568ba..53d41eb 100644 --- a/recolecta_app/lib/features/home/citizen_home_screen.dart +++ b/recolecta_app/lib/features/home/citizen_home_screen.dart @@ -52,6 +52,7 @@ class _EtaResult { mensaje.contains('15 minutos') || mensaje.contains('Está atendiendo'); double get progreso { + if (status == 'diferida' || status == 'reasignada') return 0.0; if (isNearby) return 0.85; if (isCompleted) return 1.0; return 0.35; @@ -60,6 +61,7 @@ class _EtaResult { /// Índice para el widget ProgressSteps (0 = inicio, 1 = en ruta, 2 = cerca, /// 3 = atendiendo, 4 = completado). Ajusta los valores según tu enum real. int get stepIndex { + if (status == 'diferida' || status == 'reasignada') return 0; if (isCompleted) return 4; if (isNearby) return 3; if (status == 'en_ruta') return 2; @@ -90,7 +92,13 @@ class _EtaNotifier extends AsyncNotifier<_EtaResult> { } Future refresh() async { - state = await AsyncValue.guard(_fetch); + try { + final newData = await _fetch(); + state = AsyncValue.data(newData); + } catch (e) { + // HACKATHON: Si hay un micro-corte (backend reiniciando), conservamos los datos previos + if (!state.hasValue) state = const AsyncValue.loading(); + } } Future<_EtaResult> _fetch() async { @@ -111,6 +119,23 @@ class _EtaNotifier extends AsyncNotifier<_EtaResult> { if (items.isEmpty) return const _EtaResult.noAddress(); final addressId = items.first['id'] as String; + final rawRoute = items.first['route_id'] ?? items.first['routeId'] ?? items.first['route']; + String? routeId = rawRoute?.toString(); + + // 🚨 HACKATHON FALLBACK: Si el backend no envía la ruta, la deducimos por la colonia + if (routeId == null || routeId.isEmpty) { + final col = items.first['colonia']?.toString().toLowerCase() ?? ''; + if (col.contains('centro')) routeId = 'RUTA-01'; + else if (col.contains('arboledas')) routeId = 'RUTA-03'; + else if (col.contains('juanico')) routeId = 'RUTA-04'; + else if (col.contains('olivos')) routeId = 'RUTA-05'; + else if (col.contains('seco')) routeId = 'RUTA-12'; + else if (col.contains('insurgentes')) routeId = 'RUTA-13'; + } + + Future.microtask(() { + ref.read(activeRouteIdProvider.notifier).set(routeId); + }); final etaResp = await dio.get( '/eta', queryParameters: {'address_id': addressId}, @@ -211,10 +236,7 @@ class _CitizenHomeScreenState extends ConsumerState ), body: etaAsync.when( loading: () => const _EtaLoading(), - error: (e, _) => _EtaError( - error: e.toString(), - onRetry: () => ref.read(etaProvider.notifier).refresh(), - ), + error: (e, _) => const _EtaLoading(), // Si hay error, mostramos carga infinita hasta que el backend despierte data: (result) => result.hasAddress ? _EtaContent(result: result) : _NoAddressState(onAdd: () => context.go('/addresses/new')), @@ -259,8 +281,10 @@ class _EtaContent extends StatelessWidget { ), const SizedBox(height: 12), // ── 3. Pasos de progreso (justo debajo del domicilio) ─────────── - ProgressSteps(stepIndex: result.stepIndex), - const SizedBox(height: 12), + if (result.status != 'diferida') ...[ + ProgressSteps(stepIndex: result.stepIndex), + const SizedBox(height: 12), + ], // ── 4. Banner de prevención ───────────────────────────────────── const PreventionBanner(), @@ -413,12 +437,14 @@ class _EtaHeroCard extends StatelessWidget { Color _bgColor(BuildContext context) { final cs = Theme.of(context).colorScheme; + if (result.status == 'diferida') return const Color(0xFFFFEBEE); // Alerta roja suave if (result.isCompleted) return cs.surfaceContainerHighest; if (result.isNearby) return const Color(0xFFFFF8E1); // amber-50 return const Color(0xFFE8D5DB); // rosa claro institucional } Color _accentColor(BuildContext context) { + if (result.status == 'diferida') return AppTheme.danger; // Rojo de alerta if (result.isCompleted) return Theme.of(context).colorScheme.outline; if (result.isNearby) return const Color(0xFFC8A36A); // beige dorado return const Color(0xFF9B1B4A); // vino principal @@ -496,29 +522,51 @@ class _EtaHeroCard extends StatelessWidget { const SizedBox(height: 16), // Barra de progreso - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: result.progreso, - backgroundColor: accent.withOpacity(0.2), - valueColor: AlwaysStoppedAnimation(accent), - minHeight: 8, + if (result.status != 'diferida') ...[ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: result.progreso, + backgroundColor: accent.withOpacity(0.2), + valueColor: AlwaysStoppedAnimation(accent), + minHeight: 8, + ), ), - ), - const SizedBox(height: 6), - Row( - children: [ - Text( - 'Inicio de ruta', - style: TextStyle(fontSize: 10, color: accent.withOpacity(0.7)), + const SizedBox(height: 6), + Row( + children: [ + Text( + 'Inicio de ruta', + style: TextStyle(fontSize: 10, color: accent.withOpacity(0.7)), + ), + const Spacer(), + Text( + 'Tu casa', + style: TextStyle(fontSize: 10, color: accent.withOpacity(0.7)), + ), + ], + ), + ] else ...[ + Container( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Icon(Icons.access_time_filled, size: 16, color: accent), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Servicio matutino suspendido. Se retomará en la tarde.', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: accent, + ), + ), + ), + ], ), - const Spacer(), - Text( - 'Tu casa', - style: TextStyle(fontSize: 10, color: accent.withOpacity(0.7)), - ), - ], - ), + ), + ], ], ), ); @@ -548,7 +596,11 @@ class _StatusPill extends StatelessWidget { @override Widget build(BuildContext context) { - final label = result.isNearby + final label = result.status == 'diferida' + ? 'Servicio interrumpido' + : result.status == 'reasignada' + ? 'Ruta reasignada' + : result.isNearby ? 'Cerca de tu domicilio' : result.isCompleted ? 'Servicio completado' @@ -557,8 +609,8 @@ class _StatusPill extends StatelessWidget { return Row( mainAxisSize: MainAxisSize.min, children: [ - if (!result.isCompleted) _PulsingDot(color: accent), - if (!result.isCompleted) const SizedBox(width: 6), + if (!result.isCompleted && result.status != 'diferida') _PulsingDot(color: accent), + if (!result.isCompleted && result.status != 'diferida') const SizedBox(width: 6), Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( diff --git a/recolecta_app/lib/features/separation_guide/ai_chat_provider.dart b/recolecta_app/lib/features/separation_guide/ai_chat_provider.dart index 9bf563d..fd81b24 100644 --- a/recolecta_app/lib/features/separation_guide/ai_chat_provider.dart +++ b/recolecta_app/lib/features/separation_guide/ai_chat_provider.dart @@ -65,8 +65,10 @@ class AiChatNotifier extends Notifier { role: 'system', content: 'Eres Eco, la mascota virtual de la app Recolecta en Celaya. ' - 'Tu misión es educar a los ciudadanos sobre cómo separar la basura en 4 categorías: ' + 'Tu ÚNICA misión es educar a los ciudadanos sobre recolección y separación de basura en 4 categorías: ' 'Orgánicos (verde), Reciclables (azul), Sanitarios (naranja) y Especiales (morado). ' + 'REGLA ESTRICTA: Si el usuario te hace una pregunta que NO esté relacionada con basura, reciclaje o recolección (por ejemplo: matemáticas, código, chistes, historia, etc.), ' + 'DEBES negarte a contestar amablemente y recordarle que solo eres un asistente ambiental. ' 'Responde siempre de forma muy amigable, entusiasta, usando emojis. ' 'Sé muy conciso y breve (máximo 3 oraciones cortas). ' 'Nunca reveles ubicaciones de camiones ni te salgas del tema del reciclaje y medio ambiente.',