Co-authored-by: MENDOZA BALLARDO GAEL RICARDO <gael-meb123@users.noreply.github.com>
version final final ya enserio la final del proyecto :)
This commit is contained in:
@@ -3,11 +3,13 @@ Endpoints de administración — Solo accesibles para usuarios con role='admin'.
|
|||||||
|
|
||||||
Operan directamente contra Supabase (RLS bypaseado por service_role).
|
Operan directamente contra Supabase (RLS bypaseado por service_role).
|
||||||
"""
|
"""
|
||||||
|
import traceback
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from app.core.deps import get_current_user, require_role
|
from app.core.deps import get_current_user, require_role
|
||||||
from app.core.supabase_client import supabase_admin
|
from app.core.supabase_client import supabase_admin
|
||||||
|
from app.services import notifications
|
||||||
from app.schemas.admin import (
|
from app.schemas.admin import (
|
||||||
AdminUser,
|
AdminUser,
|
||||||
AdminUserCreate,
|
AdminUserCreate,
|
||||||
@@ -213,10 +215,30 @@ def update_route(route_id: str, body: AdminRouteUpdate):
|
|||||||
payload = body.model_dump(exclude_none=True)
|
payload = body.model_dump(exclude_none=True)
|
||||||
if not payload:
|
if not payload:
|
||||||
raise HTTPException(400, "Sin cambios")
|
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:
|
try:
|
||||||
supabase_admin.table("routes").update(payload).eq("id", route_id).execute()
|
supabase_admin.table("routes").update(payload).eq("id", route_id).execute()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(400, f"Error al actualizar la ruta: {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 = (
|
res = (
|
||||||
supabase_admin.table("routes")
|
supabase_admin.table("routes")
|
||||||
.select(_ROUTE_COLS)
|
.select(_ROUTE_COLS)
|
||||||
@@ -270,6 +292,33 @@ def update_unit(unit_id: int, body: AdminUnitUpdate):
|
|||||||
supabase_admin.table("units").update(payload).eq("id", unit_id).execute()
|
supabase_admin.table("units").update(payload).eq("id", unit_id).execute()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(400, f"Error al actualizar la unidad: {e}")
|
raise HTTPException(400, f"Error al actualizar la unidad: {e}")
|
||||||
|
|
||||||
|
# ── 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 = (
|
res = (
|
||||||
supabase_admin.table("units")
|
supabase_admin.table("units")
|
||||||
.select("id, plate, status")
|
.select("id, plate, status")
|
||||||
@@ -277,6 +326,16 @@ def update_unit(unit_id: int, body: AdminUnitUpdate):
|
|||||||
.maybe_single()
|
.maybe_single()
|
||||||
.execute()
|
.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:
|
if not res.data:
|
||||||
raise HTTPException(404, "Unidad no encontrada")
|
raise HTTPException(404, "Unidad no encontrada")
|
||||||
return AdminUnit(**res.data)
|
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")),
|
"id": str(row.get("id")),
|
||||||
"unit_id": row.get("unit_id"),
|
"unit_id": row.get("unit_id"),
|
||||||
"route_id": route_id,
|
"route_id": route_id,
|
||||||
"type": row.get("category"),
|
"type": row.get("type"),
|
||||||
"description": row.get("description"),
|
"description": row.get("description"),
|
||||||
"driver_name": driver_name,
|
"driver_name": driver_name,
|
||||||
"status": row.get("status") or "open",
|
"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")
|
@router.get("/units/{unit_id}/incidents")
|
||||||
def list_unit_incidents(unit_id: int):
|
def list_unit_incidents(unit_id: int):
|
||||||
"""Reportes ciudadanos asociados a esta unidad, más reciente primero."""
|
"""Reportes ciudadanos asociados a esta unidad, más reciente primero."""
|
||||||
# Verifica que la unidad exista (devuelve 404 si no)
|
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 = (
|
unit_res = (
|
||||||
supabase_admin.table("units")
|
supabase_admin.table("units")
|
||||||
.select("id")
|
.select("id")
|
||||||
.eq("id", unit_id)
|
.eq("id", unit_id)
|
||||||
.maybe_single()
|
.limit(1)
|
||||||
.execute()
|
.execute()
|
||||||
)
|
)
|
||||||
if not unit_res.data:
|
except Exception as e:
|
||||||
raise HTTPException(404, "Unidad no encontrada")
|
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:
|
try:
|
||||||
res = (
|
res = (
|
||||||
supabase_admin.table("incidents")
|
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)
|
.eq("unit_id", unit_id)
|
||||||
.order("created_at", desc=True)
|
.order("created_at", desc=True)
|
||||||
.execute()
|
.execute()
|
||||||
)
|
)
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(500, f"Error al listar incidencias: {e}")
|
|
||||||
|
|
||||||
rows = res.data or []
|
rows = res.data or []
|
||||||
route_id = _route_for_unit(unit_id)
|
except Exception as 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")
|
||||||
|
|
||||||
# Pre-cargar el mapa de nombres una sola vez
|
# 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
|
||||||
|
|
||||||
|
try:
|
||||||
users_res = supabase_admin.table("users").select("id, name").execute()
|
users_res = supabase_admin.table("users").select("id, name").execute()
|
||||||
users_map: dict[str, dict] = {
|
users_map: dict[str, dict] = {
|
||||||
str(u["id"]): {"name": u.get("name")} for u in (users_res.data or [])
|
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)
|
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]
|
return [_serialize_incident(r, route_id, driver_name) for r in rows]
|
||||||
|
|
||||||
@@ -496,20 +580,23 @@ def create_unit_incident(
|
|||||||
description = description or category
|
description = description or category
|
||||||
|
|
||||||
# La unidad debe existir
|
# La unidad debe existir
|
||||||
|
try:
|
||||||
unit_res = (
|
unit_res = (
|
||||||
supabase_admin.table("units")
|
supabase_admin.table("units")
|
||||||
.select("id")
|
.select("id")
|
||||||
.eq("id", unit_id)
|
.eq("id", unit_id)
|
||||||
.maybe_single()
|
.limit(1)
|
||||||
.execute()
|
.execute()
|
||||||
)
|
)
|
||||||
if not unit_res.data:
|
except Exception as e:
|
||||||
raise HTTPException(404, "Unidad no encontrada")
|
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 = {
|
payload = {
|
||||||
"user_id": current_user["user_id"],
|
"user_id": current_user["user_id"],
|
||||||
"unit_id": unit_id,
|
"unit_id": unit_id,
|
||||||
"category": category,
|
"type": category,
|
||||||
"description": description,
|
"description": description,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -97,29 +97,46 @@ def register(body: RegisterRequest):
|
|||||||
|
|
||||||
# Guardar dirección inicial si viene en el payload (evita un segundo HTTP call desde Flutter)
|
# Guardar dirección inicial si viene en el payload (evita un segundo HTTP call desde Flutter)
|
||||||
saved_route_id: str | None = None
|
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:
|
try:
|
||||||
from app.services.simulation import get_colonias
|
from app.services.simulation import get_colonias
|
||||||
mapping = get_colonias()
|
mapping = get_colonias()
|
||||||
match = next(
|
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,
|
None,
|
||||||
)
|
)
|
||||||
if match:
|
if match:
|
||||||
addr_data: dict = {
|
addr_data: dict = {
|
||||||
"user_id": str(auth_user.id),
|
"user_id": str(auth_user.id),
|
||||||
"label": body.address_label or "Mi Casa",
|
"label": label,
|
||||||
"calle": body.address_calle,
|
"calle": calle,
|
||||||
"colonia": body.address_colonia,
|
"colonia": colonia,
|
||||||
"route_id": match["routeId"],
|
"route_id": match["routeId"],
|
||||||
"verified": False,
|
"verified": False,
|
||||||
}
|
}
|
||||||
if body.address_lat is not None:
|
if lat is not None:
|
||||||
addr_data["lat"] = body.address_lat
|
addr_data["lat"] = lat
|
||||||
if body.address_lng is not None:
|
if lng is not None:
|
||||||
addr_data["lng"] = body.address_lng
|
addr_data["lng"] = lng
|
||||||
|
|
||||||
|
try:
|
||||||
supabase_admin.table("addresses").insert(addr_data).execute()
|
supabase_admin.table("addresses").insert(addr_data).execute()
|
||||||
saved_route_id = match["routeId"]
|
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:
|
except Exception as e:
|
||||||
print(f"[register] No se pudo guardar la dirección inicial: {e}")
|
print(f"[register] No se pudo guardar la dirección inicial: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,55 @@ def get_eta(
|
|||||||
if current_user["role"] != "admin" and address["user_id"] != current_user["user_id"]:
|
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")
|
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)
|
pos = simulation.get_route_position(route_id)
|
||||||
status = simulation.get_route_status(route_id)
|
status = simulation.get_route_status(route_id)
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,15 @@ class RegisterRequest(BaseModel):
|
|||||||
role: Literal["citizen", "driver", "admin"] = "citizen"
|
role: Literal["citizen", "driver", "admin"] = "citizen"
|
||||||
# Dirección inicial (opcional, se guarda en el mismo request para evitar un segundo HTTP call)
|
# Dirección inicial (opcional, se guarda en el mismo request para evitar un segundo HTTP call)
|
||||||
address_label: Optional[str] = None
|
address_label: Optional[str] = None
|
||||||
|
addressLabel: Optional[str] = None
|
||||||
address_calle: Optional[str] = None
|
address_calle: Optional[str] = None
|
||||||
|
addressCalle: Optional[str] = None
|
||||||
address_colonia: Optional[str] = None
|
address_colonia: Optional[str] = None
|
||||||
|
addressColonia: Optional[str] = None
|
||||||
address_lat: Optional[float] = None
|
address_lat: Optional[float] = None
|
||||||
|
addressLat: Optional[float] = None
|
||||||
address_lng: Optional[float] = None
|
address_lng: Optional[float] = None
|
||||||
|
addressLng: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class IncidentOut(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
user_id: str
|
user_id: str
|
||||||
unit_id: Optional[int] = None
|
unit_id: Optional[int] = None
|
||||||
category: IncidentCategory
|
type: IncidentCategory
|
||||||
description: str
|
description: str
|
||||||
photo_url: Optional[str] = None
|
photo_url: Optional[str] = None
|
||||||
status: IncidentStatus
|
status: IncidentStatus
|
||||||
@@ -28,5 +28,5 @@ class IncidentOut(BaseModel):
|
|||||||
class IncidentCreate(BaseModel):
|
class IncidentCreate(BaseModel):
|
||||||
"""Payload usado cuando NO se sube foto (JSON)."""
|
"""Payload usado cuando NO se sube foto (JSON)."""
|
||||||
unit_id: Optional[int] = None
|
unit_id: Optional[int] = None
|
||||||
category: IncidentCategory
|
type: IncidentCategory
|
||||||
description: str = Field(min_length=3, max_length=1000)
|
description: str = Field(min_length=3, max_length=1000)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
@@ -59,9 +60,6 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
|
|||||||
case 0:
|
case 0:
|
||||||
await _showUserForm();
|
await _showUserForm();
|
||||||
break;
|
break;
|
||||||
case 1:
|
|
||||||
await _showRouteForm();
|
|
||||||
break;
|
|
||||||
case 2:
|
case 2:
|
||||||
await _showUnitForm();
|
await _showUnitForm();
|
||||||
break;
|
break;
|
||||||
@@ -132,17 +130,13 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
|
|||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
children: const [_UsersTab(), _RoutesTab(), _TrucksTab()],
|
children: const [_UsersTab(), _RoutesTab(), _TrucksTab()],
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
floatingActionButton: _activeTab == 1
|
||||||
|
? null // Oculta el botón flotante en la pestaña de Rutas
|
||||||
|
: FloatingActionButton.extended(
|
||||||
onPressed: _handleAdd,
|
onPressed: _handleAdd,
|
||||||
backgroundColor: AppTheme.primary,
|
backgroundColor: AppTheme.primary,
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
label: Text(
|
label: Text(_activeTab == 0 ? 'Nuevo usuario' : 'Nueva unidad'),
|
||||||
_activeTab == 0
|
|
||||||
? 'Nuevo usuario'
|
|
||||||
: _activeTab == 1
|
|
||||||
? 'Nueva ruta'
|
|
||||||
: 'Nueva unidad',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -289,18 +283,17 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Formulario ruta ─────────────────────────────────────────────────────────
|
// ── Formulario para Reasignar Ruta (Solo Vespertinas) ───────────────────────
|
||||||
Future<void> _showRouteForm({AdminRouteModel? route}) async {
|
Future<void> _showReassignRoute(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<FormState>();
|
|
||||||
final units = ref
|
final units = ref
|
||||||
.read(adminUnitsProvider)
|
.read(adminUnitsProvider)
|
||||||
.maybeWhen(data: (u) => u, orElse: () => <AdminUnitModel>[]);
|
.maybeWhen(
|
||||||
|
data: (u) => u.where((x) => x.status == 'active').toList(),
|
||||||
|
orElse: () => <AdminUnitModel>[],
|
||||||
|
);
|
||||||
|
|
||||||
|
int? selectedUnitId;
|
||||||
|
final formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
final saved = await showDialog<bool>(
|
final saved = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -312,112 +305,47 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
|
|||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
||||||
),
|
),
|
||||||
title: Text(isEdit ? 'Editar ruta' : 'Nueva ruta'),
|
title: const Text('Reasignar Unidad'),
|
||||||
content: Form(
|
content: Form(
|
||||||
key: formKey,
|
key: formKey,
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_textField(
|
Text(
|
||||||
id,
|
'Ruta: ${route.displayName}',
|
||||||
'ID (ej. RUTA-01)',
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
required: true,
|
|
||||||
enabled: !isEdit,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 8),
|
||||||
_textField(nombre, 'Nombre'),
|
const Text(
|
||||||
const SizedBox(height: 10),
|
'Selecciona una unidad activa para cubrir este turno vespertino:',
|
||||||
DropdownButtonFormField<String?>(
|
style: TextStyle(
|
||||||
initialValue: turno,
|
fontSize: 13,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<int>(
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Turno',
|
labelText: 'Nueva Unidad',
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(
|
borderRadius: BorderRadius.circular(
|
||||||
AppTheme.radiusMd,
|
AppTheme.radiusMd,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
items: const [
|
validator: (v) =>
|
||||||
DropdownMenuItem<String?>(
|
v == null ? 'Selecciona una unidad' : null,
|
||||||
value: null,
|
items: units.map((u) {
|
||||||
child: Text('—'),
|
return DropdownMenuItem(
|
||||||
),
|
|
||||||
DropdownMenuItem<String?>(
|
|
||||||
value: 'matutino',
|
|
||||||
child: Text('Matutino'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem<String?>(
|
|
||||||
value: 'vespertino',
|
|
||||||
child: Text('Vespertino'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
onChanged: (v) => setStateDialog(() => turno = v),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
DropdownButtonFormField<String>(
|
|
||||||
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<int?>(
|
|
||||||
initialValue: truckId,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Unidad asignada',
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(
|
|
||||||
AppTheme.radiusMd,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
items: [
|
|
||||||
const DropdownMenuItem<int?>(
|
|
||||||
value: null,
|
|
||||||
child: Text('Sin asignar'),
|
|
||||||
),
|
|
||||||
...units.map(
|
|
||||||
(u) => DropdownMenuItem<int?>(
|
|
||||||
value: u.id,
|
value: u.id,
|
||||||
child: Text('${u.displayPlate} (#${u.id})'),
|
child: Text('${u.displayPlate} (#${u.id})'),
|
||||||
),
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (v) =>
|
||||||
|
setStateDialog(() => selectedUnitId = v),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
onChanged: (v) => setStateDialog(() => truckId = v),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
@@ -429,31 +357,17 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
|
|||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
if (!formKey.currentState!.validate()) return;
|
if (!formKey.currentState!.validate()) return;
|
||||||
try {
|
try {
|
||||||
if (isEdit) {
|
|
||||||
await _service.updateRoute(
|
await _service.updateRoute(
|
||||||
route.id,
|
route.id,
|
||||||
name: nombre.text.trim(),
|
truckId: selectedUnitId,
|
||||||
truckId: truckId,
|
status: 'reasignada',
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (ctx.mounted) Navigator.pop(ctx, true);
|
if (ctx.mounted) Navigator.pop(ctx, true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_snack('Error: ${_errMsg(e)}', error: true);
|
_snack('Error: ${_errMsg(e)}', error: true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text('Guardar'),
|
child: const Text('Confirmar'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -464,7 +378,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
|
|||||||
|
|
||||||
if (saved == true) {
|
if (saved == true) {
|
||||||
ref.invalidate(adminRoutesProvider);
|
ref.invalidate(adminRoutesProvider);
|
||||||
_snack(isEdit ? 'Ruta actualizada' : 'Ruta creada');
|
_snack('Ruta reasignada exitosamente');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -810,39 +724,28 @@ class _RoutesTab extends ConsumerWidget {
|
|||||||
color: AppTheme.textSecondary,
|
color: AppTheme.textSecondary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (unit != null &&
|
||||||
|
(unit.status == 'inactive' ||
|
||||||
|
unit.status == 'maintenance') &&
|
||||||
|
r.turno?.toLowerCase() == 'vespertino') ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Align(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
alignment: Alignment.centerRight,
|
||||||
children: [
|
child: FilledButton.icon(
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final state = context
|
final state = context
|
||||||
.findAncestorStateOfType<_AdminScreenState>();
|
.findAncestorStateOfType<_AdminScreenState>();
|
||||||
state?._showRouteForm(route: r);
|
state?._showReassignRoute(r);
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.edit_outlined, size: 18),
|
icon: const Icon(Icons.swap_horiz, size: 18),
|
||||||
label: const Text('Editar'),
|
label: const Text('Reasignar unidad'),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.primary,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
),
|
),
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -869,6 +772,39 @@ class _RoutesTab extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Tab Unidades ──────────────────────────────────────────────────────────────
|
// ── 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<int, String> _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 {
|
class _TrucksTab extends ConsumerWidget {
|
||||||
const _TrucksTab();
|
const _TrucksTab();
|
||||||
|
|
||||||
@@ -941,7 +877,7 @@ class _TrucksTab extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'Conductor: ${assignedDriver?.displayName ?? 'Sin asignar'}',
|
'Conductor: ${assignedDriver?.displayName ?? _staticDriversByUnit[t.id] ?? 'Sin asignar'}',
|
||||||
style: const TextStyle(fontSize: 13),
|
style: const TextStyle(fontSize: 13),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
@@ -1104,6 +1040,8 @@ class _IncidentsSheetState extends ConsumerState<_IncidentsSheet> {
|
|||||||
child: async.when(
|
child: async.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) => Center(
|
error: (e, _) => Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@@ -1114,7 +1052,7 @@ class _IncidentsSheetState extends ConsumerState<_IncidentsSheet> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
e.toString(),
|
_formatIncidentError(e),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
@@ -1131,6 +1069,7 @@ class _IncidentsSheetState extends ConsumerState<_IncidentsSheet> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
data: (incidents) {
|
data: (incidents) {
|
||||||
if (incidents.isEmpty) {
|
if (incidents.isEmpty) {
|
||||||
return const Center(
|
return const Center(
|
||||||
@@ -1301,7 +1240,9 @@ class _IncidentCard extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
// ── Conductor ─────────────────────────────────────────
|
// ── Conductor ─────────────────────────────────────────
|
||||||
if (incident.driverName != null) ...[
|
if ((incident.driverName ??
|
||||||
|
_staticDriversByUnit[incident.unitId]) !=
|
||||||
|
null) ...[
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -1313,7 +1254,8 @@ class _IncidentCard extends StatelessWidget {
|
|||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
incident.driverName!,
|
incident.driverName ??
|
||||||
|
_staticDriversByUnit[incident.unitId]!,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
|
|||||||
@@ -203,6 +203,25 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onRegister() {
|
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);
|
final auth = ref.read(authControllerProvider.notifier);
|
||||||
auth.register(
|
auth.register(
|
||||||
name: _nameCtrl.text,
|
name: _nameCtrl.text,
|
||||||
|
|||||||
@@ -101,8 +101,13 @@ class _EtaNotifier extends AsyncNotifier<_EtaResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> refresh() async {
|
Future<void> refresh() async {
|
||||||
// Eliminamos el estado "loading" explícito para evitar que la UI parpadee
|
try {
|
||||||
state = await AsyncValue.guard(_fetch);
|
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 {
|
Future<_EtaResult> _fetch() async {
|
||||||
@@ -219,10 +224,8 @@ class _EtaScreenState extends ConsumerState<EtaScreen>
|
|||||||
),
|
),
|
||||||
body: etaAsync.when(
|
body: etaAsync.when(
|
||||||
loading: () => const _EtaLoading(),
|
loading: () => const _EtaLoading(),
|
||||||
error: (e, _) => _EtaError(
|
error: (e, _) =>
|
||||||
error: e.toString(),
|
const _EtaLoading(), // Si hay error, mostramos carga infinita hasta que el backend despierte
|
||||||
onRetry: () => ref.read(etaProvider.notifier).refresh(),
|
|
||||||
),
|
|
||||||
data: (result) => result.hasAddress
|
data: (result) => result.hasAddress
|
||||||
? _EtaContent(result: result)
|
? _EtaContent(result: result)
|
||||||
: _NoAddressState(onAdd: () => context.go('/addresses/new')),
|
: _NoAddressState(onAdd: () => context.go('/addresses/new')),
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class _EtaResult {
|
|||||||
mensaje.contains('15 minutos') || mensaje.contains('Está atendiendo');
|
mensaje.contains('15 minutos') || mensaje.contains('Está atendiendo');
|
||||||
|
|
||||||
double get progreso {
|
double get progreso {
|
||||||
|
if (status == 'diferida' || status == 'reasignada') return 0.0;
|
||||||
if (isNearby) return 0.85;
|
if (isNearby) return 0.85;
|
||||||
if (isCompleted) return 1.0;
|
if (isCompleted) return 1.0;
|
||||||
return 0.35;
|
return 0.35;
|
||||||
@@ -60,6 +61,7 @@ class _EtaResult {
|
|||||||
/// Índice para el widget ProgressSteps (0 = inicio, 1 = en ruta, 2 = cerca,
|
/// Índice para el widget ProgressSteps (0 = inicio, 1 = en ruta, 2 = cerca,
|
||||||
/// 3 = atendiendo, 4 = completado). Ajusta los valores según tu enum real.
|
/// 3 = atendiendo, 4 = completado). Ajusta los valores según tu enum real.
|
||||||
int get stepIndex {
|
int get stepIndex {
|
||||||
|
if (status == 'diferida' || status == 'reasignada') return 0;
|
||||||
if (isCompleted) return 4;
|
if (isCompleted) return 4;
|
||||||
if (isNearby) return 3;
|
if (isNearby) return 3;
|
||||||
if (status == 'en_ruta') return 2;
|
if (status == 'en_ruta') return 2;
|
||||||
@@ -90,7 +92,13 @@ class _EtaNotifier extends AsyncNotifier<_EtaResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> refresh() async {
|
Future<void> 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 {
|
Future<_EtaResult> _fetch() async {
|
||||||
@@ -111,6 +119,23 @@ class _EtaNotifier extends AsyncNotifier<_EtaResult> {
|
|||||||
if (items.isEmpty) return const _EtaResult.noAddress();
|
if (items.isEmpty) return const _EtaResult.noAddress();
|
||||||
|
|
||||||
final addressId = items.first['id'] as String;
|
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<dynamic>(
|
final etaResp = await dio.get<dynamic>(
|
||||||
'/eta',
|
'/eta',
|
||||||
queryParameters: {'address_id': addressId},
|
queryParameters: {'address_id': addressId},
|
||||||
@@ -211,10 +236,7 @@ class _CitizenHomeScreenState extends ConsumerState<CitizenHomeScreen>
|
|||||||
),
|
),
|
||||||
body: etaAsync.when(
|
body: etaAsync.when(
|
||||||
loading: () => const _EtaLoading(),
|
loading: () => const _EtaLoading(),
|
||||||
error: (e, _) => _EtaError(
|
error: (e, _) => const _EtaLoading(), // Si hay error, mostramos carga infinita hasta que el backend despierte
|
||||||
error: e.toString(),
|
|
||||||
onRetry: () => ref.read(etaProvider.notifier).refresh(),
|
|
||||||
),
|
|
||||||
data: (result) => result.hasAddress
|
data: (result) => result.hasAddress
|
||||||
? _EtaContent(result: result)
|
? _EtaContent(result: result)
|
||||||
: _NoAddressState(onAdd: () => context.go('/addresses/new')),
|
: _NoAddressState(onAdd: () => context.go('/addresses/new')),
|
||||||
@@ -259,8 +281,10 @@ class _EtaContent extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
// ── 3. Pasos de progreso (justo debajo del domicilio) ───────────
|
// ── 3. Pasos de progreso (justo debajo del domicilio) ───────────
|
||||||
|
if (result.status != 'diferida') ...[
|
||||||
ProgressSteps(stepIndex: result.stepIndex),
|
ProgressSteps(stepIndex: result.stepIndex),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
|
||||||
// ── 4. Banner de prevención ─────────────────────────────────────
|
// ── 4. Banner de prevención ─────────────────────────────────────
|
||||||
const PreventionBanner(),
|
const PreventionBanner(),
|
||||||
@@ -413,12 +437,14 @@ class _EtaHeroCard extends StatelessWidget {
|
|||||||
|
|
||||||
Color _bgColor(BuildContext context) {
|
Color _bgColor(BuildContext context) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
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.isCompleted) return cs.surfaceContainerHighest;
|
||||||
if (result.isNearby) return const Color(0xFFFFF8E1); // amber-50
|
if (result.isNearby) return const Color(0xFFFFF8E1); // amber-50
|
||||||
return const Color(0xFFE8D5DB); // rosa claro institucional
|
return const Color(0xFFE8D5DB); // rosa claro institucional
|
||||||
}
|
}
|
||||||
|
|
||||||
Color _accentColor(BuildContext context) {
|
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.isCompleted) return Theme.of(context).colorScheme.outline;
|
||||||
if (result.isNearby) return const Color(0xFFC8A36A); // beige dorado
|
if (result.isNearby) return const Color(0xFFC8A36A); // beige dorado
|
||||||
return const Color(0xFF9B1B4A); // vino principal
|
return const Color(0xFF9B1B4A); // vino principal
|
||||||
@@ -496,6 +522,7 @@ class _EtaHeroCard extends StatelessWidget {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Barra de progreso
|
// Barra de progreso
|
||||||
|
if (result.status != 'diferida') ...[
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
child: LinearProgressIndicator(
|
child: LinearProgressIndicator(
|
||||||
@@ -519,6 +546,27 @@ class _EtaHeroCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
] 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -548,7 +596,11 @@ class _StatusPill extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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'
|
? 'Cerca de tu domicilio'
|
||||||
: result.isCompleted
|
: result.isCompleted
|
||||||
? 'Servicio completado'
|
? 'Servicio completado'
|
||||||
@@ -557,8 +609,8 @@ class _StatusPill extends StatelessWidget {
|
|||||||
return Row(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (!result.isCompleted) _PulsingDot(color: accent),
|
if (!result.isCompleted && result.status != 'diferida') _PulsingDot(color: accent),
|
||||||
if (!result.isCompleted) const SizedBox(width: 6),
|
if (!result.isCompleted && result.status != 'diferida') const SizedBox(width: 6),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|||||||
@@ -65,8 +65,10 @@ class AiChatNotifier extends Notifier<ChatState> {
|
|||||||
role: 'system',
|
role: 'system',
|
||||||
content:
|
content:
|
||||||
'Eres Eco, la mascota virtual de la app Recolecta en Celaya. '
|
'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). '
|
'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. '
|
'Responde siempre de forma muy amigable, entusiasta, usando emojis. '
|
||||||
'Sé muy conciso y breve (máximo 3 oraciones cortas). '
|
'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.',
|
'Nunca reveles ubicaciones de camiones ni te salgas del tema del reciclaje y medio ambiente.',
|
||||||
|
|||||||
Reference in New Issue
Block a user