From 68d04f3917a2279f2294971799be1dcc165ded0d Mon Sep 17 00:00:00 2001 From: shinra32 Date: Sat, 23 May 2026 03:58:03 -0600 Subject: [PATCH] Co-authored-by: eddgranados12 Co-authored-by: MENDOZA BALLARDO GAEL RICARDO Co-authored-by: Azareth-Tr modificacion de vistas panel admin, login, animaciones y implementacion de mascota --- backend/app/api/admin.py | 377 ++++ backend/app/api/auth.py | 4 +- backend/app/schemas/admin.py | 91 + backend/app/schemas/auth.py | 1 + backend/main.py | 4 + recolecta_app/assets/animations/.gitkeep | 0 recolecta_app/assets/animations/saludo.mp4 | Bin 0 -> 58582 bytes recolecta_app/lib/core/router/app_router.dart | 14 +- .../lib/core/services/auth_controller.dart | 24 +- .../lib/core/services/auth_service.dart | 2 + .../features/addresses/add_address_page.dart | 26 +- .../lib/features/admin/admin_screen.dart | 1570 +++++++++++++++-- .../features/admin/data/admin_service.dart | 191 ++ .../features/admin/models/admin_driver.dart | 31 + .../features/admin/models/admin_route.dart | 29 + .../lib/features/admin/models/admin_unit.dart | 16 + .../lib/features/admin/models/admin_user.dart | 34 + .../admin/providers/admin_providers.dart | 23 + .../lib/features/auth/login_page.dart | 360 ++-- .../lib/features/auth/register_page.dart | 548 ++++-- .../features/auth/widgets/video_mascot.dart | 31 + .../lib/features/eta/eta_provider.dart | 49 + .../lib/features/eta/eta_screen.dart | 752 +++++--- .../lib/features/eta/eta_service.dart | 25 + .../notifications/notification_service.dart | 130 ++ .../notifications/notifications_screen.dart | 380 +++- .../lib/features/splash/splash_screen.dart | 312 ++++ .../lib/shared/widgets/prevention_banner.dart | 51 + .../lib/shared/widgets/progress_steps.dart | 174 ++ .../Flutter/GeneratedPluginRegistrant.swift | 4 + recolecta_app/pubspec.lock | 112 ++ recolecta_app/pubspec.yaml | 3 + views_v3/admin_screen.dart | 463 ++++- 33 files changed, 5188 insertions(+), 643 deletions(-) create mode 100644 backend/app/api/admin.py create mode 100644 backend/app/schemas/admin.py create mode 100644 recolecta_app/assets/animations/.gitkeep create mode 100644 recolecta_app/assets/animations/saludo.mp4 create mode 100644 recolecta_app/lib/features/admin/data/admin_service.dart create mode 100644 recolecta_app/lib/features/admin/models/admin_driver.dart create mode 100644 recolecta_app/lib/features/admin/models/admin_route.dart create mode 100644 recolecta_app/lib/features/admin/models/admin_unit.dart create mode 100644 recolecta_app/lib/features/admin/models/admin_user.dart create mode 100644 recolecta_app/lib/features/admin/providers/admin_providers.dart create mode 100644 recolecta_app/lib/features/auth/widgets/video_mascot.dart create mode 100644 recolecta_app/lib/features/eta/eta_provider.dart create mode 100644 recolecta_app/lib/features/eta/eta_service.dart create mode 100644 recolecta_app/lib/features/notifications/notification_service.dart create mode 100644 recolecta_app/lib/features/splash/splash_screen.dart create mode 100644 recolecta_app/lib/shared/widgets/prevention_banner.dart create mode 100644 recolecta_app/lib/shared/widgets/progress_steps.dart diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py new file mode 100644 index 0000000..a937e96 --- /dev/null +++ b/backend/app/api/admin.py @@ -0,0 +1,377 @@ +""" +Endpoints de administración — Solo accesibles para usuarios con role='admin'. + +Operan directamente contra Supabase (RLS bypaseado por service_role). +""" +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException + +from app.core.deps import require_role +from app.core.supabase_client import supabase_admin +from app.schemas.admin import ( + AdminUser, + AdminUserCreate, + AdminUserUpdate, + AdminRoute, + AdminRouteCreate, + AdminRouteUpdate, + AdminUnit, + AdminUnitCreate, + AdminUnitUpdate, + AdminDriver, + AdminDriverCreate, + AdminDriverUpdate, +) + +router = APIRouter( + prefix="/admin", + tags=["admin"], + dependencies=[Depends(require_role("admin"))], +) + + +# ── Helpers ─────────────────────────────────────────────────────────────────── +def _auth_user_map() -> dict[str, dict]: + """Devuelve {user_id: {email, phone}} desde Supabase Auth (paginado).""" + mapping: dict[str, dict] = {} + try: + page = 1 + while True: + resp = supabase_admin.auth.admin.list_users(page=page, per_page=200) + users = getattr(resp, "users", None) or ( + resp if isinstance(resp, list) else [] + ) + if not users: + break + for u in users: + mapping[str(u.id)] = { + "email": getattr(u, "email", None), + "phone": getattr(u, "phone", None), + } + if len(users) < 200: + break + page += 1 + except Exception as e: + print(f"[admin] list_users falló: {e}") + return mapping + + +def _auth_user(user_id: str) -> dict: + try: + resp = supabase_admin.auth.admin.get_user_by_id(user_id) + u = getattr(resp, "user", None) or resp + return { + "email": getattr(u, "email", None), + "phone": getattr(u, "phone", None), + } + except Exception: + return {"email": None, "phone": None} + + +# ── Users ───────────────────────────────────────────────────────────────────── +@router.get("/users", response_model=list[AdminUser]) +def list_users(): + res = supabase_admin.table("users").select("id, name, role").execute() + rows = res.data or [] + auth_map = _auth_user_map() + return [ + AdminUser( + id=str(r["id"]), + name=r.get("name"), + role=r.get("role", "citizen"), + email=auth_map.get(str(r["id"]), {}).get("email"), + phone=auth_map.get(str(r["id"]), {}).get("phone"), + ) + for r in rows + ] + + +@router.post("/users", response_model=AdminUser, status_code=201) +def create_user(body: AdminUserCreate): + if not body.email and not body.phone: + raise HTTPException(400, "Se requiere email o teléfono") + if len(body.password) < 6: + raise HTTPException(400, "La contraseña debe tener al menos 6 caracteres") + + create_attrs: dict = {"password": body.password} + if body.email: + create_attrs["email"] = body.email + create_attrs["email_confirm"] = True + else: + create_attrs["phone"] = body.phone + create_attrs["phone_confirm"] = True + + try: + resp = supabase_admin.auth.admin.create_user(create_attrs) + except Exception as e: + raise HTTPException(400, f"Error al crear el usuario en Supabase Auth: {e}") + + auth_user = getattr(resp, "user", None) + if not auth_user: + raise HTTPException(500, "Supabase Auth no devolvió un usuario válido") + + try: + supabase_admin.table("users").upsert( + {"id": str(auth_user.id), "name": body.name, "role": body.role} + ).execute() + except Exception as e: + raise HTTPException(500, f"Error al guardar el usuario en public.users: {e}") + + return AdminUser( + id=str(auth_user.id), + name=body.name, + email=body.email, + phone=body.phone, + role=body.role, + ) + + +@router.patch("/users/{user_id}", response_model=AdminUser) +def update_user(user_id: str, body: AdminUserUpdate): + public_payload: dict = {} + if body.name is not None: + public_payload["name"] = body.name + if body.role is not None: + public_payload["role"] = body.role + if public_payload: + try: + supabase_admin.table("users").update(public_payload).eq( + "id", user_id + ).execute() + except Exception as e: + raise HTTPException(500, f"Error al actualizar public.users: {e}") + + if body.email is not None: + try: + supabase_admin.auth.admin.update_user_by_id( + user_id, {"email": body.email} + ) + except Exception as e: + raise HTTPException(500, f"Error al actualizar email: {e}") + + res = ( + supabase_admin.table("users") + .select("id, name, role") + .eq("id", user_id) + .maybe_single() + .execute() + ) + if not res.data: + raise HTTPException(404, "Usuario no encontrado") + auth = _auth_user(user_id) + return AdminUser( + id=str(res.data["id"]), + name=res.data.get("name"), + role=res.data.get("role", "citizen"), + email=auth.get("email"), + phone=auth.get("phone"), + ) + + +@router.delete("/users/{user_id}", status_code=204) +def delete_user(user_id: str): + try: + supabase_admin.auth.admin.delete_user(user_id) + except Exception as e: + raise HTTPException(500, f"Error al borrar el usuario: {e}") + try: + supabase_admin.table("users").delete().eq("id", user_id).execute() + except Exception: + pass + return None + + +# ── Routes ──────────────────────────────────────────────────────────────────── +_ROUTE_COLS = "id, name, truck_id, turno, status, current_position_id" + + +@router.get("/routes", response_model=list[AdminRoute]) +def list_routes(): + res = supabase_admin.table("routes").select(_ROUTE_COLS).execute() + return [AdminRoute(**r) for r in (res.data or [])] + + +@router.post("/routes", response_model=AdminRoute, status_code=201) +def create_route(body: AdminRouteCreate): + payload = body.model_dump(exclude_none=True) + try: + res = ( + supabase_admin.table("routes") + .insert(payload) + .execute() + ) + except Exception as e: + raise HTTPException(400, f"Error al crear la ruta: {e}") + row = (res.data or [None])[0] + if not row: + raise HTTPException(500, "Supabase no devolvió la fila creada") + return AdminRoute(**row) + + +@router.patch("/routes/{route_id}", response_model=AdminRoute) +def update_route(route_id: str, body: AdminRouteUpdate): + payload = body.model_dump(exclude_none=True) + if not payload: + raise HTTPException(400, "Sin cambios") + 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}") + res = ( + supabase_admin.table("routes") + .select(_ROUTE_COLS) + .eq("id", route_id) + .maybe_single() + .execute() + ) + if not res.data: + raise HTTPException(404, "Ruta no encontrada") + return AdminRoute(**res.data) + + +@router.delete("/routes/{route_id}", status_code=204) +def delete_route(route_id: str): + try: + supabase_admin.table("routes").delete().eq("id", route_id).execute() + except Exception as e: + raise HTTPException(400, f"Error al borrar la ruta: {e}") + return None + + +# ── Units ───────────────────────────────────────────────────────────────────── +@router.get("/units", response_model=list[AdminUnit]) +def list_units(): + res = supabase_admin.table("units").select("id, plate, status").execute() + return [AdminUnit(**r) for r in (res.data or [])] + + +@router.post("/units", response_model=AdminUnit, status_code=201) +def create_unit(body: AdminUnitCreate): + try: + res = ( + supabase_admin.table("units") + .insert(body.model_dump(exclude_none=True)) + .execute() + ) + except Exception as e: + raise HTTPException(400, f"Error al crear la unidad: {e}") + row = (res.data or [None])[0] + if not row: + raise HTTPException(500, "Supabase no devolvió la fila creada") + return AdminUnit(**row) + + +@router.patch("/units/{unit_id}", response_model=AdminUnit) +def update_unit(unit_id: int, body: AdminUnitUpdate): + payload = body.model_dump(exclude_none=True) + if not payload: + raise HTTPException(400, "Sin cambios") + try: + 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() + ) + if not res.data: + raise HTTPException(404, "Unidad no encontrada") + return AdminUnit(**res.data) + + +@router.delete("/units/{unit_id}", status_code=204) +def delete_unit(unit_id: int): + try: + supabase_admin.table("units").delete().eq("id", unit_id).execute() + except Exception as e: + raise HTTPException(400, f"Error al borrar la unidad: {e}") + return None + + +# ── Drivers ─────────────────────────────────────────────────────────────────── +def _hydrate_driver(row: dict, users_map: dict[str, dict], units_map: dict[int, dict]) -> AdminDriver: + user_id = str(row.get("user_id")) + unit_id: Optional[int] = row.get("unit_id") + u = users_map.get(user_id, {}) + unit = units_map.get(unit_id, {}) if unit_id is not None else {} + return AdminDriver( + id=str(row["id"]), + user_id=user_id, + user_name=u.get("name"), + user_email=u.get("email"), + unit_id=unit_id, + plate=unit.get("plate"), + ) + + +def _drivers_context() -> tuple[dict[str, dict], dict[int, dict]]: + users_res = supabase_admin.table("users").select("id, name").execute() + auth_map = _auth_user_map() + users_map: dict[str, dict] = { + str(u["id"]): { + "name": u.get("name"), + "email": auth_map.get(str(u["id"]), {}).get("email"), + } + for u in (users_res.data or []) + } + units_res = supabase_admin.table("units").select("id, plate").execute() + units_map: dict[int, dict] = {u["id"]: u for u in (units_res.data or [])} + return users_map, units_map + + +@router.get("/drivers", response_model=list[AdminDriver]) +def list_drivers(): + res = supabase_admin.table("drivers").select("id, user_id, unit_id").execute() + users_map, units_map = _drivers_context() + return [_hydrate_driver(r, users_map, units_map) for r in (res.data or [])] + + +@router.post("/drivers", response_model=AdminDriver, status_code=201) +def create_driver(body: AdminDriverCreate): + try: + res = ( + supabase_admin.table("drivers") + .insert(body.model_dump(exclude_none=True)) + .execute() + ) + except Exception as e: + raise HTTPException(400, f"Error al crear el conductor: {e}") + row = (res.data or [None])[0] + if not row: + raise HTTPException(500, "Supabase no devolvió la fila creada") + users_map, units_map = _drivers_context() + return _hydrate_driver(row, users_map, units_map) + + +@router.patch("/drivers/{driver_id}", response_model=AdminDriver) +def update_driver(driver_id: str, body: AdminDriverUpdate): + payload = body.model_dump(exclude_none=True) + if not payload: + raise HTTPException(400, "Sin cambios") + try: + supabase_admin.table("drivers").update(payload).eq("id", driver_id).execute() + except Exception as e: + raise HTTPException(400, f"Error al actualizar el conductor: {e}") + res = ( + supabase_admin.table("drivers") + .select("id, user_id, unit_id") + .eq("id", driver_id) + .maybe_single() + .execute() + ) + if not res.data: + raise HTTPException(404, "Conductor no encontrado") + users_map, units_map = _drivers_context() + return _hydrate_driver(res.data, users_map, units_map) + + +@router.delete("/drivers/{driver_id}", status_code=204) +def delete_driver(driver_id: str): + try: + supabase_admin.table("drivers").delete().eq("id", driver_id).execute() + except Exception as e: + raise HTTPException(400, f"Error al borrar el conductor: {e}") + return None diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 1f03712..e08f378 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -74,10 +74,10 @@ def register(body: RegisterRequest): if not auth_user: raise HTTPException(status_code=400, detail="No se pudo crear el usuario en Supabase Auth") - # Crear entrada en public.users con el rol elegido + # Crear entrada en public.users con el rol y nombre elegidos try: supabase_admin.table("users").upsert( - {"id": str(auth_user.id), "role": body.role} + {"id": str(auth_user.id), "role": body.role, "name": body.name} ).execute() except Exception as e: raise HTTPException(status_code=500, detail=f"Error al guardar el usuario: {e}") diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py new file mode 100644 index 0000000..3bcaf51 --- /dev/null +++ b/backend/app/schemas/admin.py @@ -0,0 +1,91 @@ +from typing import Optional, Literal +from pydantic import BaseModel, EmailStr + + +# ── Users ───────────────────────────────────────────────────────────────────── +class AdminUser(BaseModel): + id: str + name: Optional[str] = None + email: Optional[str] = None + phone: Optional[str] = None + role: str = "citizen" + + +class AdminUserCreate(BaseModel): + name: str + password: str + email: Optional[EmailStr] = None + phone: Optional[str] = None + role: Literal["citizen", "driver", "admin"] = "citizen" + + +class AdminUserUpdate(BaseModel): + name: Optional[str] = None + email: Optional[EmailStr] = None + role: Optional[Literal["citizen", "driver", "admin"]] = None + + +# ── Routes ──────────────────────────────────────────────────────────────────── +class AdminRoute(BaseModel): + id: str + name: Optional[str] = None + truck_id: Optional[int] = None + turno: Optional[str] = None + status: str = "pendiente" + current_position_id: int = 1 + + +class AdminRouteCreate(BaseModel): + id: str + name: Optional[str] = None + truck_id: Optional[int] = None + turno: Optional[Literal["matutino", "vespertino", "Matutino", "Vespertino"]] = None + status: Optional[ + Literal["pendiente", "en_ruta", "completada", "diferida", "reasignada"] + ] = "pendiente" + + +class AdminRouteUpdate(BaseModel): + name: Optional[str] = None + truck_id: Optional[int] = None + turno: Optional[Literal["matutino", "vespertino", "Matutino", "Vespertino"]] = None + status: Optional[ + Literal["pendiente", "en_ruta", "completada", "diferida", "reasignada"] + ] = None + + +# ── Units (camiones) ────────────────────────────────────────────────────────── +class AdminUnit(BaseModel): + id: int + plate: Optional[str] = None + status: str = "active" + + +class AdminUnitCreate(BaseModel): + id: int + plate: Optional[str] = None + status: Literal["active", "inactive", "maintenance"] = "active" + + +class AdminUnitUpdate(BaseModel): + plate: Optional[str] = None + status: Optional[Literal["active", "inactive", "maintenance"]] = None + + +# ── Drivers ─────────────────────────────────────────────────────────────────── +class AdminDriver(BaseModel): + id: str + user_id: str + user_name: Optional[str] = None + user_email: Optional[str] = None + unit_id: Optional[int] = None + plate: Optional[str] = None + + +class AdminDriverCreate(BaseModel): + user_id: str + unit_id: Optional[int] = None + + +class AdminDriverUpdate(BaseModel): + unit_id: Optional[int] = None diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 70c532e..7bedd48 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -3,6 +3,7 @@ from typing import Optional, Literal class RegisterRequest(BaseModel): + name: str email: Optional[str] = None phone: Optional[str] = None password: str diff --git a/backend/main.py b/backend/main.py index dd60b14..de093ce 100644 --- a/backend/main.py +++ b/backend/main.py @@ -9,6 +9,8 @@ from app.api.auth import router as auth_router from app.api.addresses import router as addresses_router from app.api.colonias import router as colonias_router from app.api.users import router as users_router +from app.api.admin import router as admin_router +from app.api.simulation import router as simulation_router from app.services import simulation, notifications scheduler = AsyncIOScheduler() @@ -59,3 +61,5 @@ app.include_router(addresses_router) app.include_router(eta_router) app.include_router(colonias_router) app.include_router(users_router) +app.include_router(admin_router) +app.include_router(simulation_router) diff --git a/recolecta_app/assets/animations/.gitkeep b/recolecta_app/assets/animations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/recolecta_app/assets/animations/saludo.mp4 b/recolecta_app/assets/animations/saludo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..a70625eac3bc8b15a9859d35cfafcc1f0250a01d GIT binary patch literal 58582 zcmeIb1ymMY7e6|5NGd5HCEbmLgmgF3-Q6Vu(v5(0BPEEaG>C+Nq@;jKx0E0vh`=3u zUy)Do`~K_R_1wGGUC%6!XV0E9XU^>3`R#r7^YF|706=E$>g8zV;$Q~=u)t{z7Iq_d z6E-_XPBs9*M7MKra0dXu*3RA16s*r}a4P_SdmAyGA;JDlU==A)subcjJjM&uK?6l44c6uDm&Doah9~mLh#nsprl*e6M zU48;`+UE5sXkdN1o$vF90wD*yZ7EPZEvFj{t{m8&o0XY^jhT&=oy^wC*pr=`^Phu1 z{wFv&0c(D+)h!rPfc9h-xMl&?WfQ{Mur!O!+Wvrl4gkObWB`B^FptRI3of5wPo?CSil@z+$^m`ufD++%Y*dIJo^gcYf9|*TG}n{~Y@9KRDlzy+B;Tz&%;8aDxS84Qw}9 z%YlXG-}(_)D}qHDEPPb%FKiz1#rH>6l`{q6wDM^H1A+2hR@!>(l!)0A(^4 zSCiB6pKhRiFu{8zqyqpFDgYn@&!Yg1WgbSx7m05Hh~`oEC>60l%F!F|(T0SmF@-++bX@=w6} z+4lb+SRvZ|8?1bO3syGZ>cZ&Xj(!aNhYSl~oz~z`Pd5%T7gNyiARYYu{V7042@lpd z!XWP8?Sd%7d_G11HL#bl5Wx!O)Xu-+$-fOi-S6=P49*~Q0PM&)p8(FypZ?%S4R_k$ zU)^cr(;=P~1hD+nBc9VEozsJ0oV7!0wP{^~hB@i{%n!L#<)&gn_d>B-LN$VBdGa#996PbyFvl$uN4^fI4RI$h_ ziAyqZl8I>C0*{%RnSgC>IXHURnwh(jv9q#rFtM|;bAU=qS64?q78VZ=59ZTk(!tiq zp4q|Kg5|VdW=mH)Td<9TqpOvJy$c_iiIK69i69#pm@o=*keQkp+d7!o2(t09^0AT` z*&EqY}{lfmd*}#Mh4)iY-Fy^X12CgE}+Eg$!luj3JNApc7m+n z5R6Q{9qi2n+1VJ`*vQO{TwDztU2LozPh0%cfs>Xl&wq!*+JRJ)X4GlHjE97 zt&ChwFJk3v_T$Dp%&aUdU5&wG4vuE_1{MyE;L(4Sj^L>_W?o?5g6v$Z|B43SiV55$ zb1^ZqH#2c_735?+9j3FgxHFFf?B(rh^Q37WK2qM^*krOxpPT>Un!Pqch z78V5XoE-0cI!55ufLBV!?pw*^Abp8jThGb!qW?69Kl_J}S%uhI`OS0+WbqF**$jXn z2f+9OK&n5WDGaa+12n<_sW3q450K{oU{K{0K1~VE_3?jGzVr$A>4^iG*#cbDm-T`7 zyH)v8Ti+hWnBm-X+ukWpI+~Wp&xg4UIwlQ7H-cH(Jc;(|ElQdP0u>_)vyD^(bjvoQ z0-wF&Yt(||wSswIU5B;bz%`J6r|n|REMC}ra9f*lDa~b9g>8qQ2hBHo<*sY|viJK! zvN6&cse%UyIhSC1swU)aCd6-SzP6c)H(sbUO*=|GvGKWYA?VfH*W=jM^XY!7ywW5> z_(pmk6>3tG6?X5(`f?_DP7&cTXFabk@))lIxkH-{g&i4CtcuzK4;ro9T$KUfvLZ@8 zQt{&#cdntmjh?KR{$Sxvac{2LcI~M8-Z0C<<}4cCf}Iy`g9>oN57+pvl8(&OF+|X# z8VR+ru?2so3p>QO)_60JBCSr?&}^M3UwAiaBOm#dD1N21c3=09nC9m|*(n+XiEeg7 zIr|g&qr9NPSXO-EwDd>U>a;C4*^$dBEj2wEImjQb-R-9!*09$d?=9BzL)Lr_8=_*N z7DytqLuE@(bAOO^Z4ZVTI*?tJfh#ZdfBd9hheT@nK<k<;{^(CI- z32gIghV@tO%-CYf!GT#VSy-G7VuzfQ=Obhu#HXLS<&yUGD5Go1wtYVrn;Yt&1RTx| z4oITa+c%#umX*y{k!M^s@XxqIWLeL zc3bjhpKmUy%<(`fMVs#EL8QmWaEX^!V!7;N299eQHde7IJ#W7AG1S3Zk+0@vllne2 z?m@BAa@aMi@`d10SoFSv=t3LWbR;VC29IzY-RuMvW5Z~{`lOaPUS9ri&+vLp*LK%7 z0N|y$sHY%RK2PxV(8=jzs4=q?lwYS7ZF>35PwQ5#QyERO4n}WO`{Val4ntBIeTv?g z8YB!BD)+o!f2!oeql%>aAkp(c>HVi*0=3qP2Fo%!eYo#ad9haw#XQXTn)mB0u75%! zILc6}FB*J}ow7R@h#2=c=s|A}U8T*fJ+jv$LcYkefy zTX9!$pTrPvk;}^s&DSpSiIIgDX~SI=Hbf_fWhsTDCFFCI1sJ?$@ZjDZ?0;qAO`6BR zXhcRPzM5!6v~)Yl?a)lxG|R9Lb=ZuJc98Gt{pZnhZP{;O>z>+g=~m_7O}zpaN3iPv z9O(D{pL@Ljdp)3(kYkGjI7%=QC)=pn(Cw!M^j7cTR6=NL1lM{&B5BucZ}}Y;1zmtW zb+&ipd=EX{)43fC!L|TmALWjJ%NE@dxX4plD5ity6=X}>NU^BC5`dY(DW ztOi@}FkI+HK7tFJFg`@%`6j!2V)R(hpz z3MQRI(ilJVQJoB`W^YliN)B}4q$aQ}r`E|db$CbMg0b;(^D98hxPm=sHk>;CC7al) zsGEAeP_(c5nMgcC#miLYilJA-GIGZ*ez)jLe8{lEf)N+t`E1{2criALuJrSbA0guw zw^(7|cVb5gs6W3(lkvsWbI@oYwN~WLt+&YJ)|TAGdka&xg@w38`-(RVxubKri;5c# zHJrO&lCzP_-=I6H##(}8_*uZe8+0IBPQ6Sa*JMMKZ;t{>W+o-!;Hj1GM>Cr&QKX2_*+Qn2OeDj zn(ICZ1trD-`^&O4L!6VAl?9>Vujtjtolkv|*L+dX`v8HJ^)`LOcLKrUie}dJH&&PV z9j&BlGt;?@rq>W1DOdyHm8^@B`2@u4I@G2%nkqH9P5HCn{hGd%ySKdCHw<6g)4{6b7 zpw$0K_w%*hY97R>UkP#_kQB)h^$hmAfcT^Y_6`i<@bXD`==rm7T3H{jAW$uo7GPl; z{@EDD0t(-G*nY|o_1sXC|1|UY8sdy$hsiQ>6}^Zte_1%eh(M7#kI+vUqT~Qwn;1-% ziS>m*(eAC;knoMAxA-R+DOQlpwRt<>wFDal0j{L-qt^52-5|& zLnO^ox>&?ARSwzV-!HzOpL-563l1uoP3J(iA94_siN_Xty=*W_0HbY^2#D2F_efoTa(7XZ2lB2|KO+ zg!9?{{dZm55%GlXEwI8G@NY*A_tg7e^fADNMRwa}rC*jPDQ}G3jr!hyRi}2uLeG;> zC&~8X+pOrf)0f-Gq#4={qz)QI!jnlJj!`<}=aK`In_HM(BM8=AlTk3eZ>QH~w2{w6y@}ZT_!S-mU-o@xx}@C{Bi9}a3NWS)dglfB z6j8c7af?n}#+$>VEBX+y7c)Vzr*28g`?wuVX>N%?UmU5t-_voy+{@Dvo0u&P!;m1_ zNwcC~kFxF4CTxCFZ_Mg+`{pX1~uL>uade*pcVfKkrYX!?-`?sl`APBSx zs)i@SJG3FL@jq!DK3@3jEQGgUr zS{ZJl^Ep0ov~_s>7InFnkBB_e>;-uQkPF4{JZxt&M4dmBGG}%^CqSGs957i07bg-_ zk43=mh5!5zRip(9)j5RDM2NEE)VaZA75ycXSTC%%Q9f4LzuF${l_L5YPq^tD4@>7f z(0l=YXvk3b&M-SCK$dAxo1ZiFnFwjh6pEykf!T!Zf6FF{gcMlu;i%H_*GvrPr6|P< z)GFq3x|`8;ZoeHah^R<2#_0lJRcouh=DG_uxNsZAOS#^gXI?>KhugWKJ9y*CHBZII zI)$Ismi0g0n?fqgSR4|`>o-i-y_`|2pi-}OiAkA@(|{MDvwKc4$_@@yd*XLa!o7t~ zjSL5C_UlGQDad+}K1pJfR8zO0+ z{>SQtrq-5TtL@pdmqN&);VU0zRuAjCJ<2l$CQDqMZQ@IE6|ZeQ)-qu|6!v~=VeRZZFg^Y!o_QN(si6BUpXj{9`chZQxC6GL@h;~I9RjN zwU^{>8dxl6SyJz{yX6)LRkURU z^9Ow5!^Yb@^|RDZux>uN?L~_wfuk~Xt1Rp`hZ`)y$Kr_yxt0c->;R8fth*76Ej&sz z86*fk@*6k4y|4R(b=R70WcSnL`hx4^W~vS}8vmN5=Ol=F7pRXoXX!H$;y%JwBpc&z zYgp1I@gha6cd+(b$8Po^xd@bl5d{@@XZWySyBScB-U<&D(ROCYe=1gA78>aoBKj`l zzH~prLw!=Tx~IeJOIn@grE;&r`?fFco9U`-wnW{GL(CxvLn3~%oN^LJ_hk*!AfQZ? z=7i_X)wE5OjZ3|A6yqt|_uW6lr683)aUqb%d5zzAH@$^71ZFD*3CoCD4jZk7g|VL# zE0oB1c!-l{4i}lFRl=l%{y=?k7^lgJh~y=`km3 zgpNlaxo#_!Svk}W1~>7V!N7X75`J`nEf*6;)R+Ty+4pCtk0(YHXay9@1*MQf5KG&~ z{R?DK!y9&O-nVlv;KgjdA))AaGL=v?srn#8eetqnm_an}Ck1^ItCDaI3=6KkwH1a; zG=W}L-nHx3dx%EZ!CyiA=VxD(v(PzyHnGnM5OphHnkEC2&2;f1pB~w=yb0;jYo|}$ z%;R@3sf^(>2F-+JSHs`S73;p30oV-HAeV0%xg!$ zI`Z=Vr>R-*leR^J%H@xtnnhnw_;Wr!+P_QQag!I$lO(k{Yvev((#Au0C+Yis`Xz%} zA2~&Kj%tH!LhBD>)4m)SSagh5lpo_dm-ivs(T_{Q7CVm+_S?`NsC(%xCgS8M+w;pK zUfr~`d9_Q8FI#2(Jf0(!l8%#{t`5r=@3OCPFq?c&1QYm$e3Yk!WNk|#*Za+`bY@X= zw-WCgB0~~gV~O~s8XYW7eP7p(!!t@3bCI*-7dr0<`7Kf9bHF5~@YXx#R3hUQn}{sS z(%Wz=$5`xGYHsnWR`eN63BJ3>wp?_DtdU~+E6NjR^6QM&aUNUy8vTOmQ~5dS_?WiR zmm8=iBL%h=aX)*0P3!vjiH6EaSWJum&GkF(Zo2W07GK>-JPFYCWc*Sm!RmeII~EgZqEpBK!&T_Ps(zQm zO1|dRL&;vNg9f|1$g;9X&%$CiZ}{secPVcmQM>2*7hmq$2@#t7GqPon2;#Q5yc=c%i9`;~%CtG-Vm zLg`Br=Y6TTp~Q+n%Ng9^)_4sDFbKb%Yr-httE3>|I~x)Ql~JS!|lj236)`u*;N>}V5pts_~DD*MRwTTu^u zZp^$fQfr$|8=6=N!TL^ekb6+?&mfA~zjE-Zb1NR};xt3ARdSbF!L6YYpaCA|KwM(spcqvx9WmV4i8?i0KI+p)1E zc4%<<<&1twkPa@lMY2`?mUNKSuk6`Ta}qtn@*ew^bZhu&8O0-tWjKy;I&;>pOS$Vk z%buYsadJj$3oE~e8Mq=SoWDT)B|$dIKyCiZ(!V6g+8oSkL}9X>{+87gdA^wjdP)|S zdGy^Y#M_8DPapd8pUEfy2dfW7?iXIaBuG0y0hnx;i&xeRJUGRlhBH(VD0uzQpWX2P z12Y-uGWr)z=OoCI4N8-9raluPOcPiek#vv$k-1R!sHA2A>g28FOPiXZ*-?w$)a3L- zJv+|xPkC`GvrAAi!nYA04@I@ImXQeA=k4nE6q-g!=-0;^v}I6g_vZ-38TOr^JLj|2 zYtOAZKSDvVevyF)2deYgM{!60gsRPqD7qsrs}Dz5Ki!BZQBcoNPjzZ&z+Pf_$MVkLeZ$>v^b z;cL!AO(L}8cU^3kB-W$e7;1H((@#}k@`U9a6~AnmR&6(@wY4Xz@M~1NLt1V9wKuxx zUV+|8-57U<7`}odUMk`z*SD@YF7Q>KROdPzJ&mr|eZc!nYjH#g)4QEqB;i_eTrs2w zyPkyEt&EF~`6A@{R2}7Ldb5q+JcA=M(NnqJP6j3lE$=nj=X$r{X4_vY;f}Me-lErv z=f_1ps#9toPYJ!O&fMSA@@}$pue@TGd3`Tao>wQ!nWlE|lTw*Pkp8GGt64CKV$@zz zogVT^Ay$u^vYDM<`cfl3UR3Z)(vF(Dow+-(0_vIr{BIWoVa&VvbUP&j(Qeh66Pb3r z&w&>!f?uuI)*VS6eIdCV8&SeoehGbk8!;~-D7O9*dsYi8cRwAzNNjU}JA&f#tTex) z&Hh^g>&`t-ghhlE;*UMpKZ({_w|*#-$#-K$&?&6Y}579*@bRgEM3b;{2Rlaa5H24eV{v9^-Xor$~>i7e%WHSJ>M zj;*cemAxaZS4RpixxQKh+v0xTz$edkrAXS*64@kmSBt4{0*sG#>S3v~`rjvud_8)f;dn<0wjDF4tw-Js zo#WA%v0{IAj>a>W#;!i`NJZ+mY-{Rw;6xM9pD;>zmlYu9Rn6$y2c$?HS)10#nBdRd zd)P+h|M}%qf6EbRN)Ln8)wwQZ+(AMgtASr&G)!#v!@=#=^xa7)8=`4O!Xz36?g+l=s8XoK zE4C-H+}+ei(CC(9+h_OB@d?UfrVQ4hk?X7peL#WA<09_Y97k5fevgVu^bH4bg)EHl z$tY%9m>A7mp zCV=Nivn>HWa_T=nT7yxC&Ve)F&k2y_9hgr@!esAX{E;XF?MJhbDLqubvTQ9G_2!;& zl4jpF=c0B;m*(Hm0}yec@SQ{LOoTLrrD3wa{cUCYP3Gf)lHj54HA646D~|)#U1t@Z zM@jZxil3YL13wa0D1K+4of9BSKd8;mnfgqGFlS)>MAA?GM~*>rcsSEg0FdJ5l9m;c zE>p&S*=RPl(Bi9~RP2pV**GxpjSj!;Dp#LIG@_$kuCys5>-r0tY@4i-^L*GIOgc*J zUUSD?diP;8>{OF>j4-jaV}or*FoTu1BZ&o`x^d^Dd~EdzhP7jxPx^v|<+%al<3z;& ztrfR4eb2l0!Qx!tvF>x7LZrJhHT^XA5Zb0q( zHM?P5>@nR3N7}xg?VVm5$3PJP?XKnVNyc4b)UM0XEZNf{C9B1XY2sdMW>nl&^1(Ct zSYr=EJS^iAC%fMHEV$KPeoY*2Z~n@?2@&b4xcK!R+!CSkqIh_{SY9S3?k9uCBTw)b6SKXWg`Yy3(X@D6lBUbE zkWTWhP3UHd&-TX+k<*oR()d2g2Tl%yKPrQU>cb~JZyR|Jt2#Xi>V`Ao@S zm(X(d%r?GWS^!RdpE5Ng+2jlVVp6h|!NTRtn!cT9nS`s(RYxXQN43!3m~Icg>3ft> zCgf(GcSCc%bHU>|9KJ`!&~|?OIQ97IfD`g6l=Wv+KPN!e(}D{fd66uVzpr)^56U=M z?>QdjFFfAiDWVRgoVsiw?1)X+n^FS4$KXHLssJ3E4-~|6*qwtpV|4G0CC>% z!(<8kEkT&=Eh7AKXyvTkcP+E`51NQWzj;3g)GraDECD?o_ygUWe@hDH5}pMKb1ez$ z?t8!3ZZedA#D|@7IjsnM55#}^I2h>9xqbn5PJ%4mpf2v5sn0|R^99a9B#rWaB?RAl zsv_HI^~6xg;H^+!SZui`R=krPLrCn1H;}Q$OmWcl35e%R>V8<@=Cc1YzQC>#X4j!(e2ZyYTVs z#!{a4uah5IyBZ6LzkD+xbDiZ*P*;xOF0-7$1J$+=zKY8BmdBJCOu^21Iu+X<(?=Ux z7C5Zt0jPIB4#AD?Tcv*6Le;X-bYP9{dJQ1ogON6B+?kkrwxTti=CFpQ^DL$(4)-eGz`@loOLBdue90i(i`_K4vsTxxR9W8I8;Dzl z#ol_ubOUSM0S+ob%?7`-;%x26Abeu-w~?he zw+!zh5p=)UMf_$)Tz=o|`?JYO-SbW97H%O`4l_Trx(%iErnQzF3$#S=9jIY`HP`VEq zX}$VbT@SAWNPplO4n27RzFnr8eECxwvTqN=OT#cZt1t7D(ME;Rd;q0Z^ymB&%jA}O zlw9Or0&jle9d@K96Un#0LfH67FFMZ5{nqhTHHBj`@~0ABg!@zrtu4FF^}N}sM&D`m ziyAJk4AG_#WYhWec5}bhisREKiQ42E@p~CmGd|f;ZxUZf82n-eW*E11^;+IDdU6w~ zwY(K=L|7E5|l`jtq7)5UbN+Ep>mfh-o8(N0ibwuul}pK3 zdZB&^Z0*TuRJTz*HC%PoM5-~`+*};eI>1*RSr7*e+SR)zSB30WC#c3A?#Ij>*JIA| zwwO{V2fJ#Pdwt!0+ZUU8iR(qOzxbh62FCm50RJ-g1vs+@L<461nAj3sH+VxE|n!aK?#^2XJ2-l&r^xWh; z6CoUn!LK8-4F8sG6nT#0+5{b{^?X=%NXgj49CtPp?9b0C`W-s}4rUFC-Wg=)1jzar zsLjur`b>m0W58#f8~>MVZ)-1+$h%95AZh1!l=>C_iRrrL=h9M4Z^?D?AH{F ze%ly&x`C3HtH(Yg^V2g` z>JbgP0BSBVN-uN*{b|k8htr7k{-d;LS5eUnuPe`X%_G=qRHv6_GnJdGvW|HwNDO}H zcI1W`BrmGY3vn%{@KmN1G@ws^Dd9Bz_^N1qT$%QpSBIssz7IdzV)sj2y|pJY`^_;Q zTakL^W3?ZN+szlQp)#*fJ~v7_efuaJLN;c+etDLHV%`ux2x;av>ow;#UmRUtjuekY z#{-2AS0^ut6(;qPW@VZk9}`%%Ha)v3?@J!QxWAtlc+X)w@e)q&0$Ii*Wg3z%yzf3J znlBgONfyc7bXc?K^o{)XMx1T&JB68yu*9rHx*&6W(p9r-=FbqUm4-f+s4HL;PttJS zxlG`o?-}3A+}7y=lgZ}rWJ5kIVbx+=0f+R1rZ(?By|Ltb+g?$>EwZ?DS%ap^R{D1@ zTBPCkL&rAP=;7_)^xh#y>9()VItYc^n{kNy+P6ps`P8g$sAvjSP2?b=RlV(+??4b)H^IiNjeY)f8WUSQAyE+(0*F76K#&#Fs3A|oX^z5#kn_R{fnYyY-Oq{(HILT z?IU~&T=17?FomZdtpwj}|0c0zT9q$|@4_Q6VdG`jC?B_UsCPJ3% zP@A6_`kVmi*r+Iy75w*m4Np8Vp75*g;;2}zSl+DT~sK2!PVzi{G)>g2VXyA9QfLU`k6uL9K&Iu4@3z!9@ zh@_SNuPh+{&Gf#eH#@OTG5kSKy>X0=v^ngTU@TJ@VeOn(G&Xm8xUxJ3T`i0r849^y z)7{L?7!`CD7zZX}Oy8M>&e~U!)WBsKhPl%j6Xr_IHH}D3a8z#08jcbyGgpx6ItJg% zY?(?(WR;e4c}?jz`l1w(e`@}%Y;o$lZO_U6I@`=JPHs!iBttyfN4z zDEl=QLdPgZb*1Z}h>NUT3Fs^;`n)oxO0Zs}Rzyf4qGHuD)>eXP4{mbk1==3WzpW9x z7jI4Z;y&i}C286QrV|`0Q7`)IW$DIPSk9<3I*S-amR+!ci%)X6B=v7$9tV=URUkT6 z-pHXzN*!@pgy$(t_UlJoQPgwcu1Ej0Mri%~dD)d~SGSy!yVEznPUjvnddFSK+^gX? zTovlcCDzqTtYdX$l;S2A9moGJrLR9o)&O?{=kxPzbwAAd;YF_wk#4PGQ)V|nIk8;sr8yS&)}Et3rMa6qFAX?7_^8Id@L8wEQr?fP+ra*Jb^ zmN9aRMC;i7;5Jxfx>wyUbtmfuBahoeHiADDu;wT5@=KxDIWx;Tav#keWwk5I`lNHy zPO8qkx*eaOhkx^Wzv3fkR&iggZxWmTgY@xQ`Xl&yReWagNL-sD;MVGdxa#;DxjDA< zu_gFT?iKPDRz-@}1wC(^pI1QL!8tE*CPLhk%ZX%l{g+j%*?ys=md0#o0<#9cVMvs4 z=Vb@I>cu%Eg|-V^dNdTnGvLk%5OwKLlb^HnnFw*(fFFde{54Bx{Ib_?b*T?m$;r8O zLl#4?*pbKZPr5)KhI9tke+Uqz3z*>Dgvt8&x3w$b6?BpwYTEdc)Hboox!Y2IpoX3T zCES0Y_3sX32?w?L#WueOv6ps(@9c)H5lP$nAF0B>zg=Ln5f##s?V10mYY}P>cZIKO z$oxw8>te0=rah_*mg0nXvyp)rMecF}k0j41#c;d2*jvLPd2Nb~=?}IWmk?<+`lR3W zt4{mG1@#df@YVW-T@9n6$NawVFvf{Yjj1N7STIQ2xQ(2YrYm1^OQlhI3RXp#t#t~cdJ&q87#h`rRBAxq)Zeep$ zh3lZrj|JOVynTc08A(+tqY>KpI6)8>UP@u$CmjNgp0JTf7MI+SoDL1*M-jIZ^^YIb zetjyf?V#XZk}hDoArxEF*-ZcG#V2DP1S8+aDY#i)fwzu{7sZ<-ctnmi`y|pU8B79U zNB4q46euGV?5MS?(}!l&!bQRfy?SP(QI7fTmv+HFs)GB36wKrJp{L(%h%5c3J04#J zx^xtd*UR^^Pq$*l+=BA1Am}sn+bDBAx&HyziV;~NFXWhg@&iE>O;-as(KrnWiYwPW z>V>f))n=JJ>sxM=@Qwivol=DkqhFY6Wt1D_5&DlPV!t8QRx2qIG-?mZXx_ea5X+sO`b<`Ie;P+bX6LqV-W7_+ zTx57ojRLWAe6#)SMsu5bfthe@33lbz2Mk}7*e?}c*1gU{9?)d*F8%3%lw|2E&;6@e zA@$L>2Ym~L ziJ0Y6{26K#`X1?-v)Tq=R{R!~VQHX7n>ZyB$I&2X8c>;u^?Bl)=9-Z zNvxJEE`yb=KSI!>twDhQJ6!+()e4H<#ppqhFrbGNgUM$8OXk77#*u8`CCM*F+U5J9 zH@ntWqGzOC;Lo?2AwoZ6xd=IJX#R1bSsL5sqNW?zply@UPwD319F<#g1a8~GB8`|6vXKUrwzpAZee){86WCazK&>0e^9Ev zg`Ukez#08mQ7%`G+MLPbKncmrZ-r8;jy}qRk0dc1I}OKY9$6>MCUwdRQI9!8dE?d$ ztR;uMzsLt-@u5W6SJ#Z953_HMOP0nU%2~lPRo5lZj9*o%9=}t-5;`3PF5HdF_uwJH3jNl^J&-|C8-N>(Ps7IG+S*sRGO?W zRYopqE?lL<@NU4mhZu+O8NOUOnND28QK>j_LU(0L8{-~hO9qd-=}`SP4H;q%e%LPPv}tZB8=R4S+^@F{(3aA zZLy9ibZOtKuNF@YwrE!L{T2L6IDU6}TLPB5HAg&gn>($!DZkTu3Lov%J0~*RA8}VO z8|7o`ppmNQ}73PTGvGHk0q`N(hi(lU<`x-T! zDB&H;F$uu6{7DtlPuu_RXQCCPdZNE;wcZlz$`U$FU+={jD?XV^37;jq& z+|&{vQ|`C9k7kxPP~$<8XjfH-SvKfjRGo#@U-7wSDZ8b{aV!T}U=1TPf;plXCGz37 z<-;LB)?ly52!BI3F17NN;N9l8>@d*;zh#Fo(CqLpa}~0SUGSw|j(^Kko);3`Y{Mop z$MnQId7Um+e7vdH+w#p!j__Lj<=@}~0JRoqwsH}Ikaadtn_p~mh?B)F>jgc>TGt^O-GDL9)jmlz{`Fk3+Z!K5Yo+iQtzeLH`F&#sBpyi=L2` zA~yhoC)OEz*K+dHOLzGc!}jlAK84vVBGJvO&thu?IJDd%siAU74VBdq)W#XRAZKY z(-)`8!`LTt7@>EUYR;h^v%V{dO3P_eSD1Epx?~4)6FJORPm+wK*;~tq`ZJ3dUjq%bvv^B=T$cCy-Y#Ie z-M$gG^`M~K2yf(xkVT;PSM#W=PhB1o!V=@?@P5U`Z&&Yqloq#~{CpzrP0<}{zV|su z!8M4c`lLcJ?9EXnILZOM7Q%G7{wLI3MzJpRufpsY*o*c@?L_?-gm2s@xkgtuMR#IO z+fh?ZRLQ%1U*}FJD>LTTer9XkWTMwIW=JcSpJP7FEUk96aGCI&TiIhRLm5zmuR3zU zgAb3JCFf6ng`%oGmOnodK4u-W`|wCqO|11ZTkN;K!XjL`5`1-u(9MRZ9g{)=g?0nY zgBwo|>cU6`??mrKZ4DaiaI?i|sM3}PT=!iZrZr;cywnbVolo4P%681{%g1Y97(*MM zIF%5Q9f^m}8sR7faiJsR*dX59+W2g8OUPWjB@{a^e7BX{cbnb{kQjn4NY_hYyrpH|v=p5*llt_J~KC}3O`yIv2t_hr}oVrU| z1@U+f^ZeG7v@2bP3#iIzllz+J3dmI}6_#)t=mzSwOx}9wRvdQtn>Z}q8}=NC{hqjV zFXCS55fE)ZTKfer`Mm+x-q6QwCZR+^W+pR1)ZC)z@+UhzJKibyZmcupH^i&-@g@B6 z3dpjz+N^GJ-Zw%WyOJM}*TRVwNQD+ry&B4^m~?DdvWkd0(QV&epRMgxKpdzN2N!rF1b{B@0;Q$Gb}@wjs4H zEkP!D2bUtRsg>?!RWTLn5W86uvWoh%wU~<@~HeB9*H7grm4u`dS2G^W|9QG~?`ObWx^puL}qr8Lo zu?bhxky|NO@r85YadmUO1rucgnXZJ(n^L$gjR+WV6m#Xo$D#Iq?kCET)qP_hsb%ct zl#;PwVy%1{f)hg@JfLSa!OY@yT{)WhKr%M16*3w^AMN3*=iJd3H-g4d6wgqCG8a|I zBj%RR(7Sb*%I=MN&jcm^$yV{TCSf@E+*J&2;&(BA5M0_qO@6V-Axs&rzrJUJ>ai$K zWeV9J?0^GE2BBbG3>F0W0WNF*{#FUv^}=VBW6Vi(83}amA<}@!6*4{)W6UVnw8abX z(-MZ_cQJmDBpcM`7uy`NoQeGPRtbxf<1DNT=#Z4`fHe56Pk<=JEMaqGBPvt}V_o#C z&5Z&NwSA!SWFRP&IvF8#AA=KMF4PR(nF;MHmt@0SXuB1v6JgFYH-H$rOogzp6Y@6v zNFpg|`nX?mO@?@X=JzYzf_^!B(FB0__e_B5e|f_INd;?6y6c49Hscos-=RGrtb5lI z1Gp%>;?Stj6{KF05GXRH8vc$X@C5o4?!Q0@K{7-A=S4P$_-APne|^sYjmmR&(nm(_ zH5irw*ROm;uFQLOQ#3v*@c0kc2go-=(YqKu2oeTdG4lTPl>=}&+gOx2%jl`>NbiMz zcW}@J;zeLV`cERD6uHRi5dOd!*#GsG0mYM}?cb>YzJCLuZ+!E{6k>S8z%a_glvSw| zp~(9Fp^u|s?~Mf#=7-#t%tjCC1f#h|OCN|G3rBx<)=^9%0518_eZNtBx8B|ONEl;Axqp`LmCC+emvX52wI_u^Q=;J0Ynr#qY zjlu-|m&d)6Hi;M6+QNNEe@J5+Q7GwIdZVv8Q@Z1ZNxJI$+>Hz}JNV=c<~@u-e}Zpz zxDVTXmQgcO@QG>cUz+5ie^?Wja716$H<^xb&}b*laE=eskUib3tO+{;bjNbKb&yw&Jk=GWkrniizhOZB3=#Iy zfXB_E(yLzC4swoK+AWk6x|ij!-sLRCkqE;IqkO6JG3?}!%czahU^M@O(vP5jlmC~w z582g->|Y-uPmgSAgkJnXi#F1jEJ^n}0v=2XCw;&t`UQE#jmkgpg9E0Z3D{o{%!A-B z*P#oPi;NEG1&YpJA0$tYmU5MNF2Bt<2j1YTb9ZuTGX2&-crLq9t4*!CBKyIERfCsL zFc!iIT{-Cz((ref) { final authState = ref.watch(authControllerProvider); return GoRouter( - initialLocation: '/login', + initialLocation: '/splash', redirect: (BuildContext context, GoRouterState state) { final isAuthenticated = authState.value?.isAuthenticated ?? false; final role = authState.value?.userRole; - final isAuthRoute = + final isPublicRoute = + state.matchedLocation == '/splash' || state.matchedLocation == '/login' || state.matchedLocation == '/register'; if (!isAuthenticated) { - return isAuthRoute ? null : '/login'; + return isPublicRoute ? null : '/login'; } - if (isAuthRoute) { + if (isPublicRoute) { switch (role) { case 'admin': return '/admin'; @@ -72,6 +74,10 @@ final routerProvider = Provider((ref) { return null; }, routes: [ + GoRoute( + path: '/splash', + builder: (context, state) => const SplashScreen(), + ), GoRoute(path: '/login', builder: (context, state) => const LoginPage()), GoRoute( path: '/register', diff --git a/recolecta_app/lib/core/services/auth_controller.dart b/recolecta_app/lib/core/services/auth_controller.dart index 8902f6e..9cadace 100644 --- a/recolecta_app/lib/core/services/auth_controller.dart +++ b/recolecta_app/lib/core/services/auth_controller.dart @@ -44,6 +44,7 @@ class AuthController extends AsyncNotifier { } Future register({ + required String name, required String email, required String phone, required String password, @@ -55,16 +56,19 @@ class AuthController extends AsyncNotifier { }) async { state = const AsyncLoading(); try { - final session = await ref.read(authServiceProvider).register( - email: email, - phone: phone, - password: password, - addressCalle: addressCalle, - addressColonia: addressColonia, - addressLabel: addressLabel, - addressLat: addressLat, - addressLng: addressLng, - ); + final session = await ref + .read(authServiceProvider) + .register( + name: name, + email: email, + phone: phone, + password: password, + addressCalle: addressCalle, + addressColonia: addressColonia, + addressLabel: addressLabel, + addressLat: addressLat, + addressLng: addressLng, + ); final authState = AuthState.authenticated( token: session.token, diff --git a/recolecta_app/lib/core/services/auth_service.dart b/recolecta_app/lib/core/services/auth_service.dart index 5d48b41..01cd9ff 100644 --- a/recolecta_app/lib/core/services/auth_service.dart +++ b/recolecta_app/lib/core/services/auth_service.dart @@ -38,6 +38,7 @@ class AuthService { } Future register({ + required String name, required String email, required String phone, required String password, @@ -50,6 +51,7 @@ class AuthService { return _authenticate( path: '/auth/register', payload: { + 'name': name, 'email': email, 'phone': phone, 'password': password, diff --git a/recolecta_app/lib/features/addresses/add_address_page.dart b/recolecta_app/lib/features/addresses/add_address_page.dart index 3e5b8c8..9c1ba32 100644 --- a/recolecta_app/lib/features/addresses/add_address_page.dart +++ b/recolecta_app/lib/features/addresses/add_address_page.dart @@ -164,10 +164,6 @@ class _AddAddressPageState extends ConsumerState { 'calle': _calleCtrl.text.trim(), 'colonia': _selectedColonia!.nombre, }; - if (_selectedLocation != null) { - body['lat'] = _selectedLocation!.latitude; - body['lng'] = _selectedLocation!.longitude; - } await dio.post('/addresses', data: body); if (mounted) Navigator.pop(context, true); @@ -396,19 +392,17 @@ class _AddAddressPageState extends ConsumerState { color: Colors.white, ), ) - : const Row( + : const FittedBox( key: ValueKey('text'), - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.check, size: 18), - SizedBox(width: 8), - Flexible( - child: Text( - 'Guardar dirección', - overflow: TextOverflow.ellipsis, - ), - ), - ], + fit: BoxFit.scaleDown, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.check, size: 18), + SizedBox(width: 8), + Text('Guardar dirección'), + ], + ), ), ), ), diff --git a/recolecta_app/lib/features/admin/admin_screen.dart b/recolecta_app/lib/features/admin/admin_screen.dart index a3298a2..8824d56 100644 --- a/recolecta_app/lib/features/admin/admin_screen.dart +++ b/recolecta_app/lib/features/admin/admin_screen.dart @@ -1,24 +1,1100 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/services/auth_controller.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_widgets.dart'; +import 'data/admin_service.dart'; +import 'models/admin_driver.dart'; +import 'models/admin_route.dart'; +import 'models/admin_unit.dart'; +import 'models/admin_user.dart'; +import 'providers/admin_providers.dart'; -// ── Modelos locales ─────────────────────────────────────────────────────────── -enum TruckStatus { disponible, enRuta, mantenimiento, detenido } +class AdminScreen extends ConsumerStatefulWidget { + const AdminScreen({super.key}); + + @override + ConsumerState createState() => _AdminScreenState(); +} + +class _AdminScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late final TabController _tabController; + int _activeTab = 0; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this) + ..addListener(() { + if (!_tabController.indexIsChanging) { + setState(() => _activeTab = _tabController.index); + } + }); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + AdminService get _service => ref.read(adminServiceProvider); + + void _snack(String msg, {bool error = false}) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(msg), + backgroundColor: error ? AppTheme.danger : AppTheme.primary, + ), + ); + } + + Future _handleAdd() async { + switch (_activeTab) { + case 0: + await _showUserForm(); + break; + case 1: + await _showRouteForm(); + break; + case 2: + await _showUnitForm(); + break; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.background, + appBar: AppBar( + title: const Text('Panel de administración'), + actions: [ + IconButton( + tooltip: 'Refrescar', + icon: const Icon(Icons.refresh), + onPressed: () { + ref.invalidate(adminUsersProvider); + ref.invalidate(adminRoutesProvider); + ref.invalidate(adminUnitsProvider); + ref.invalidate(adminDriversProvider); + }, + ), + IconButton( + tooltip: 'Cerrar sesión', + icon: const Icon(Icons.logout), + onPressed: () async { + await ref.read(authControllerProvider.notifier).logout(); + if (mounted) context.go('/login'); + }, + ), + ], + bottom: TabBar( + controller: _tabController, + indicatorColor: Colors.white, + labelColor: Colors.white, + unselectedLabelColor: Colors.white70, + tabs: const [ + Tab(text: 'Usuarios'), + Tab(text: 'Rutas'), + Tab(text: 'Camiones'), + ], + ), + ), + body: TabBarView( + 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' + : 'Nuevo camión', + ), + ), + ); + } + + // ── Formulario usuario ────────────────────────────────────────────────────── + Future _showUserForm({AdminUserModel? user}) async { + final isEdit = user != null; + final nombre = TextEditingController(text: user?.name ?? ''); + final email = TextEditingController(text: user?.email ?? ''); + final telefono = TextEditingController(text: user?.phone ?? ''); + final password = TextEditingController(); + String role = user?.role ?? 'citizen'; + final formKey = GlobalKey(); + + final saved = await showDialog( + context: context, + builder: (ctx) { + return StatefulBuilder( + builder: (ctx, setStateDialog) { + return AlertDialog( + backgroundColor: AppTheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + ), + title: Text(isEdit ? 'Editar usuario' : 'Nuevo usuario'), + content: Form( + key: formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _textField(nombre, 'Nombre', required: true), + const SizedBox(height: 10), + _textField( + email, + 'Email', + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 10), + if (!isEdit) ...[ + _textField( + telefono, + 'Teléfono', + keyboardType: TextInputType.phone, + ), + const SizedBox(height: 10), + _textField( + password, + 'Contraseña (mín. 6)', + obscure: true, + required: true, + validator: (v) => (v == null || v.length < 6) + ? 'Mínimo 6 caracteres' + : null, + ), + const SizedBox(height: 10), + ], + DropdownButtonFormField( + initialValue: role, + decoration: InputDecoration( + labelText: 'Rol', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular( + AppTheme.radiusMd, + ), + ), + ), + items: const [ + DropdownMenuItem( + value: 'citizen', + child: Text('Ciudadano'), + ), + DropdownMenuItem( + value: 'driver', + child: Text('Conductor'), + ), + DropdownMenuItem( + value: 'admin', + child: Text('Administrador'), + ), + ], + onChanged: (v) { + if (v != null) setStateDialog(() => role = v); + }, + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancelar'), + ), + ElevatedButton( + onPressed: () async { + if (!formKey.currentState!.validate()) return; + try { + if (isEdit) { + await _service.updateUser( + user.id, + name: nombre.text.trim(), + email: email.text.trim().isEmpty + ? null + : email.text.trim(), + role: role, + ); + } else { + if (email.text.trim().isEmpty && + telefono.text.trim().isEmpty) { + _snack('Email o teléfono es requerido', error: true); + return; + } + await _service.createUser( + name: nombre.text.trim(), + password: password.text, + email: email.text.trim().isEmpty + ? null + : email.text.trim(), + phone: telefono.text.trim().isEmpty + ? null + : telefono.text.trim(), + role: role, + ); + } + if (ctx.mounted) Navigator.pop(ctx, true); + } catch (e) { + _snack('Error: ${_errMsg(e)}', error: true); + } + }, + child: const Text('Guardar'), + ), + ], + ); + }, + ); + }, + ); + + if (saved == true) { + ref.invalidate(adminUsersProvider); + ref.invalidate(adminDriversProvider); + _snack(isEdit ? 'Usuario actualizado' : 'Usuario creado'); + } + } + + // ── Formulario 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(); + final units = ref + .read(adminUnitsProvider) + .maybeWhen(data: (u) => u, orElse: () => []); + + final saved = await showDialog( + context: context, + builder: (ctx) { + return StatefulBuilder( + builder: (ctx, setStateDialog) { + return AlertDialog( + backgroundColor: AppTheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + ), + title: Text(isEdit ? 'Editar ruta' : 'Nueva ruta'), + content: Form( + key: formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _textField( + id, + 'ID (ej. RUTA-01)', + required: true, + enabled: !isEdit, + ), + 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, + ), + ), + ), + 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: 'Camión asignado', + 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), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancelar'), + ), + ElevatedButton( + 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, + ); + } + if (ctx.mounted) Navigator.pop(ctx, true); + } catch (e) { + _snack('Error: ${_errMsg(e)}', error: true); + } + }, + child: const Text('Guardar'), + ), + ], + ); + }, + ); + }, + ); + + if (saved == true) { + ref.invalidate(adminRoutesProvider); + _snack(isEdit ? 'Ruta actualizada' : 'Ruta creada'); + } + } + + // ── Formulario camión (unit) ──────────────────────────────────────────────── + Future _showUnitForm({AdminUnitModel? unit}) async { + final isEdit = unit != null; + final idCtrl = TextEditingController(text: unit?.id.toString() ?? ''); + final plate = TextEditingController(text: unit?.plate ?? ''); + String status = unit?.status ?? 'active'; + final formKey = GlobalKey(); + + final saved = await showDialog( + context: context, + builder: (ctx) { + return StatefulBuilder( + builder: (ctx, setStateDialog) { + return AlertDialog( + backgroundColor: AppTheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + ), + title: Text(isEdit ? 'Editar camión' : 'Nuevo camión'), + content: Form( + key: formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _textField( + idCtrl, + 'ID numérico (ej. 101)', + keyboardType: TextInputType.number, + required: true, + enabled: !isEdit, + validator: (v) { + if (v == null || v.trim().isEmpty) return 'Requerido'; + if (int.tryParse(v) == null) + return 'Debe ser numérico'; + return null; + }, + ), + const SizedBox(height: 10), + _textField(plate, 'Placa'), + const SizedBox(height: 10), + DropdownButtonFormField( + initialValue: status, + decoration: InputDecoration( + labelText: 'Estado', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular( + AppTheme.radiusMd, + ), + ), + ), + items: const [ + DropdownMenuItem( + value: 'active', + child: Text('Activo'), + ), + DropdownMenuItem( + value: 'inactive', + child: Text('Inactivo'), + ), + DropdownMenuItem( + value: 'maintenance', + child: Text('Mantenimiento'), + ), + ], + onChanged: (v) { + if (v != null) setStateDialog(() => status = v); + }, + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancelar'), + ), + ElevatedButton( + onPressed: () async { + if (!formKey.currentState!.validate()) return; + try { + if (isEdit) { + await _service.updateUnit( + unit.id, + plate: plate.text.trim().isEmpty + ? null + : plate.text.trim(), + status: status, + ); + } else { + await _service.createUnit( + id: int.parse(idCtrl.text.trim()), + plate: plate.text.trim().isEmpty + ? null + : plate.text.trim(), + status: status, + ); + } + if (ctx.mounted) Navigator.pop(ctx, true); + } catch (e) { + _snack('Error: ${_errMsg(e)}', error: true); + } + }, + child: const Text('Guardar'), + ), + ], + ); + }, + ); + }, + ); + + if (saved == true) { + ref.invalidate(adminUnitsProvider); + ref.invalidate(adminRoutesProvider); + _snack(isEdit ? 'Camión actualizado' : 'Camión creado'); + } + } + + // ── Helpers ───────────────────────────────────────────────────────────────── + Widget _textField( + TextEditingController controller, + String label, { + TextInputType keyboardType = TextInputType.text, + bool obscure = false, + bool required = false, + bool enabled = true, + String? Function(String?)? validator, + }) { + return TextFormField( + controller: controller, + keyboardType: keyboardType, + obscureText: obscure, + enabled: enabled, + validator: + validator ?? + (required + ? (v) => + (v == null || v.trim().isEmpty) ? 'Campo requerido' : null + : null), + decoration: InputDecoration( + labelText: label, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + ), + ), + ); + } + + String _errMsg(Object e) { + final s = e.toString(); + return s.length > 220 ? '${s.substring(0, 220)}…' : s; + } +} + +// ── Tabs ────────────────────────────────────────────────────────────────────── +class _UsersTab extends ConsumerWidget { + const _UsersTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(adminUsersProvider); + return async.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => _ErrorView( + message: e.toString(), + onRetry: () => ref.invalidate(adminUsersProvider), + ), + data: (users) { + if (users.isEmpty) { + return const _EmptyView('No hay usuarios registrados.'); + } + return ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), + itemCount: users.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, i) { + final u = users[i]; + return AppCard( + child: Row( + children: [ + CircleAvatar( + backgroundColor: AppTheme.primaryLight, + foregroundColor: AppTheme.primary, + child: Text(u.initials), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + u.displayName, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + if (u.email != null && u.email!.isNotEmpty) + Text( + u.email!, + style: const TextStyle( + fontSize: 13, + color: AppTheme.textSecondary, + ), + ), + if (u.phone != null && u.phone!.isNotEmpty) + Text(u.phone!, style: const TextStyle(fontSize: 13)), + const SizedBox(height: 4), + _roleBadge(u.role), + ], + ), + ), + IconButton( + icon: const Icon( + Icons.edit_outlined, + color: AppTheme.primary, + ), + onPressed: () { + final state = context + .findAncestorStateOfType<_AdminScreenState>(); + state?._showUserForm(user: u); + }, + ), + IconButton( + icon: const Icon( + Icons.delete_outline, + color: AppTheme.danger, + ), + onPressed: () => _confirmAndDelete( + context, + tipo: 'usuario', + onConfirm: () async { + await ref.read(adminServiceProvider).deleteUser(u.id); + ref.invalidate(adminUsersProvider); + ref.invalidate(adminDriversProvider); + }, + ), + ), + ], + ), + ); + }, + ); + }, + ); + } + + Widget _roleBadge(String role) { + switch (role) { + case 'admin': + return AppStatusBadge.amber('Administrador'); + case 'driver': + return AppStatusBadge.green('Conductor'); + default: + return AppStatusBadge.gray('Ciudadano'); + } + } +} + +class _RoutesTab extends ConsumerWidget { + const _RoutesTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(adminRoutesProvider); + final units = ref + .watch(adminUnitsProvider) + .maybeWhen(data: (u) => u, orElse: () => []); + + return async.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => _ErrorView( + message: e.toString(), + onRetry: () => ref.invalidate(adminRoutesProvider), + ), + data: (routes) { + if (routes.isEmpty) { + return const _EmptyView('No hay rutas registradas.'); + } + return ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), + itemCount: routes.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, i) { + final r = routes[i]; + AdminUnitModel? unit; + if (r.truckId != null) { + for (final u in units) { + if (u.id == r.truckId) { + unit = u; + break; + } + } + } + return AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + r.displayName, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + _routeStatusBadge(r.status), + ], + ), + const SizedBox(height: 6), + Text( + 'ID: ${r.id}', + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + if (r.turno != null) + Text( + 'Turno: ${r.turno}', + style: const TextStyle(fontSize: 13), + ), + Text( + 'Camión: ${unit?.displayPlate ?? (r.truckId == null ? 'Sin asignar' : '#${r.truckId}')}', + style: const TextStyle( + fontSize: 13, + color: AppTheme.textSecondary, + ), + ), + Text( + 'Posición actual: ${r.currentPositionId}/8', + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + onPressed: () { + final state = context + .findAncestorStateOfType<_AdminScreenState>(); + state?._showRouteForm(route: 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, + ), + ), + ], + ), + ], + ), + ); + }, + ); + }, + ); + } + + Widget _routeStatusBadge(String status) { + switch (status) { + case 'en_ruta': + return AppStatusBadge.amber('En ruta'); + case 'completada': + return AppStatusBadge.green('Completada'); + case 'diferida': + return AppStatusBadge.danger('Diferida'); + case 'reasignada': + return AppStatusBadge.amber('Reasignada'); + default: + return AppStatusBadge.gray('Pendiente'); + } + } +} + +class _TrucksTab extends ConsumerWidget { + const _TrucksTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(adminUnitsProvider); + final routes = ref + .watch(adminRoutesProvider) + .maybeWhen(data: (r) => r, orElse: () => []); + final drivers = ref + .watch(adminDriversProvider) + .maybeWhen(data: (d) => d, orElse: () => []); + + return async.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => _ErrorView( + message: e.toString(), + onRetry: () => ref.invalidate(adminUnitsProvider), + ), + data: (units) { + if (units.isEmpty) { + return const _EmptyView('No hay camiones registrados.'); + } + return ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), + itemCount: units.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, i) { + final t = units[i]; + AdminRouteModel? assignedRoute; + for (final r in routes) { + if (r.truckId == t.id) { + assignedRoute = r; + break; + } + } + AdminDriverModel? assignedDriver; + for (final d in drivers) { + if (d.unitId == t.id) { + assignedDriver = d; + break; + } + } + return AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + t.displayPlate, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + _unitStatusBadge(t.status), + ], + ), + const SizedBox(height: 6), + Text( + 'ID: #${t.id}', + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + Text( + 'Conductor: ${assignedDriver?.displayName ?? 'Sin asignar'}', + style: const TextStyle(fontSize: 13), + ), + Text( + 'Ruta: ${assignedRoute?.displayName ?? 'Sin asignar'}', + style: const TextStyle( + fontSize: 13, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + onPressed: () { + final state = context + .findAncestorStateOfType<_AdminScreenState>(); + state?._showUnitForm(unit: t); + }, + icon: const Icon(Icons.edit_outlined, size: 18), + label: const Text('Editar'), + ), + const SizedBox(width: 8), + TextButton.icon( + onPressed: () => _confirmAndDelete( + context, + tipo: 'camión', + onConfirm: () async { + await ref + .read(adminServiceProvider) + .deleteUnit(t.id); + ref.invalidate(adminUnitsProvider); + ref.invalidate(adminRoutesProvider); + }, + ), + icon: const Icon(Icons.delete_outline, size: 18), + label: const Text('Eliminar'), + style: TextButton.styleFrom( + foregroundColor: AppTheme.danger, + ), + ), + ], + ), + ], + ), + ); + }, + ); + }, + ); + } + + Widget _unitStatusBadge(String status) { + switch (status) { + case 'inactive': + return AppStatusBadge.gray('Inactivo'); + case 'maintenance': + return AppStatusBadge.amber('Mantenimiento'); + default: + return AppStatusBadge.green('Activo'); + } + } +} + +// ── Shared widgets ──────────────────────────────────────────────────────────── +class _EmptyView extends StatelessWidget { + const _EmptyView(this.message); + final String message; + + @override + Widget build(BuildContext context) => Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + message, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 15, color: AppTheme.textSecondary), + ), + ), + ); +} + +class _ErrorView extends StatelessWidget { + const _ErrorView({required this.message, required this.onRetry}); + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) => Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, color: AppTheme.danger, size: 48), + const SizedBox(height: 12), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Reintentar'), + ), + ], + ), + ), + ); +} + +void _confirmAndDelete( + BuildContext context, { + required String tipo, + required Future Function() onConfirm, +}) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppTheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + ), + title: Text('Eliminar $tipo'), + content: Text('¿Deseas eliminar este $tipo?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + style: TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), + child: const Text('Cancelar'), + ), + TextButton( + onPressed: () async { + Navigator.pop(ctx); + try { + await onConfirm(); + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$tipo eliminado'))); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error: $e'), + backgroundColor: AppTheme.danger, + ), + ); + } + } + }, + style: TextButton.styleFrom(foregroundColor: AppTheme.danger), + child: const Text('Eliminar'), + ), + ], + ), + ); +} + +// ── Legacy stubs (no longer used; kept enum to avoid breaking imports) ──────── +enum _LegacyTruckStatus { disponible, enRuta, mantenimiento, detenido } extension TruckStatusX on TruckStatus { String get label => switch (this) { - TruckStatus.disponible => 'Disponible', - TruckStatus.enRuta => 'En ruta', - TruckStatus.mantenimiento => 'Mantenimiento', - TruckStatus.detenido => 'Detenido', - }; + TruckStatus.disponible => 'Disponible', + TruckStatus.enRuta => 'En ruta', + TruckStatus.mantenimiento => 'Mantenimiento', + TruckStatus.detenido => 'Detenido', + }; AppStatusBadge get badge => switch (this) { - TruckStatus.disponible => AppStatusBadge.green(label), - TruckStatus.enRuta => AppStatusBadge.amber(label), - TruckStatus.mantenimiento => AppStatusBadge.gray(label), - TruckStatus.detenido => AppStatusBadge.gray(label), - }; + TruckStatus.disponible => AppStatusBadge.green(label), + TruckStatus.enRuta => AppStatusBadge.amber(label), + TruckStatus.mantenimiento => AppStatusBadge.gray(label), + TruckStatus.detenido => AppStatusBadge.gray(label), + }; } class _AdminUser { @@ -34,27 +1110,63 @@ class _AdminUser { String get iniciales => '${nombre.isNotEmpty ? nombre[0] : ''}${apellido.isNotEmpty ? apellido[0] : ''}' .toUpperCase(); - _AdminUser copyWith({String? nombre, String? apellido, String? email, String? telefono}) => - _AdminUser(id: id, nombre: nombre ?? this.nombre, apellido: apellido ?? this.apellido, email: email ?? this.email, telefono: telefono ?? this.telefono); + _AdminUser copyWith({ + String? nombre, + String? apellido, + String? email, + String? telefono, + }) => _AdminUser( + id: id, + nombre: nombre ?? this.nombre, + apellido: apellido ?? this.apellido, + email: email ?? this.email, + telefono: telefono ?? this.telefono, + ); } class _AdminRoute { final String id, nombre, zona; final bool activa; - const _AdminRoute({required this.id, required this.nombre, required this.zona, this.activa = true}); + const _AdminRoute({ + required this.id, + required this.nombre, + required this.zona, + this.activa = true, + }); _AdminRoute copyWith({String? nombre, String? zona, bool? activa}) => - _AdminRoute(id: id, nombre: nombre ?? this.nombre, zona: zona ?? this.zona, activa: activa ?? this.activa); + _AdminRoute( + id: id, + nombre: nombre ?? this.nombre, + zona: zona ?? this.zona, + activa: activa ?? this.activa, + ); } class _AdminTruck { final String id, placas, modelo, conductor, rutaId; final TruckStatus status; const _AdminTruck({ - required this.id, required this.placas, required this.modelo, - required this.conductor, required this.status, required this.rutaId, + required this.id, + required this.placas, + required this.modelo, + required this.conductor, + required this.status, + required this.rutaId, }); - _AdminTruck copyWith({String? placas, String? modelo, String? conductor, TruckStatus? status, String? rutaId}) => - _AdminTruck(id: id, placas: placas ?? this.placas, modelo: modelo ?? this.modelo, conductor: conductor ?? this.conductor, status: status ?? this.status, rutaId: rutaId ?? this.rutaId); + _AdminTruck copyWith({ + String? placas, + String? modelo, + String? conductor, + TruckStatus? status, + String? rutaId, + }) => _AdminTruck( + id: id, + placas: placas ?? this.placas, + modelo: modelo ?? this.modelo, + conductor: conductor ?? this.conductor, + status: status ?? this.status, + rutaId: rutaId ?? this.rutaId, + ); } // ── Pantalla ────────────────────────────────────────────────────────────────── @@ -71,18 +1183,49 @@ class _AdminScreenState extends State int _activeTab = 0; final List<_AdminUser> _usuarios = [ - const _AdminUser(id: 'u-01', nombre: 'Laura', apellido: 'Gómez', email: 'laura@recolecta.com', telefono: '+52 461 987 1234'), - const _AdminUser(id: 'u-02', nombre: 'Miguel', apellido: 'Sánchez', email: 'miguel@recolecta.com', telefono: '+52 461 123 7890'), + const _AdminUser( + id: 'u-01', + nombre: 'Laura', + apellido: 'Gómez', + email: 'laura@recolecta.com', + telefono: '+52 461 987 1234', + ), + const _AdminUser( + id: 'u-02', + nombre: 'Miguel', + apellido: 'Sánchez', + email: 'miguel@recolecta.com', + telefono: '+52 461 123 7890', + ), ]; final List<_AdminRoute> _rutas = [ const _AdminRoute(id: 'RUTA-01', nombre: 'Ruta Norte', zona: 'Zona Norte'), - const _AdminRoute(id: 'RUTA-02', nombre: 'Ruta Sur', zona: 'Zona Sur', activa: false), + const _AdminRoute( + id: 'RUTA-02', + nombre: 'Ruta Sur', + zona: 'Zona Sur', + activa: false, + ), ]; final List<_AdminTruck> _camiones = [ - const _AdminTruck(id: 't-01', placas: 'GTO-101', modelo: 'Volvo FH', conductor: 'Javier Pérez', status: TruckStatus.enRuta, rutaId: 'RUTA-01'), - const _AdminTruck(id: 't-02', placas: 'GTO-103', modelo: 'Mercedes 1830', conductor: 'Ana Díaz', status: TruckStatus.disponible, rutaId: 'RUTA-02'), + const _AdminTruck( + id: 't-01', + placas: 'GTO-101', + modelo: 'Volvo FH', + conductor: 'Javier Pérez', + status: TruckStatus.enRuta, + rutaId: 'RUTA-01', + ), + const _AdminTruck( + id: 't-02', + placas: 'GTO-103', + modelo: 'Mercedes 1830', + conductor: 'Ana Díaz', + status: TruckStatus.disponible, + rutaId: 'RUTA-02', + ), ]; @override @@ -122,20 +1265,25 @@ class _AdminScreenState extends State ), body: TabBarView( controller: _tabController, - children: [ - _buildUsersTab(), - _buildRoutesTab(), - _buildTrucksTab(), - ], + children: [_buildUsersTab(), _buildRoutesTab(), _buildTrucksTab()], ), floatingActionButton: FloatingActionButton.extended( onPressed: () { - if (_activeTab == 0) _showUserForm(); - else if (_activeTab == 1) _showRouteForm(); - else _showTruckForm(); + if (_activeTab == 0) + _showUserForm(); + else if (_activeTab == 1) + _showRouteForm(); + else + _showTruckForm(); }, backgroundColor: AppTheme.primary, - label: Text(_activeTab == 0 ? 'Nuevo usuario' : _activeTab == 1 ? 'Nueva ruta' : 'Nuevo camión'), + label: Text( + _activeTab == 0 + ? 'Nuevo usuario' + : _activeTab == 1 + ? 'Nueva ruta' + : 'Nuevo camión', + ), icon: const Icon(Icons.add), ), ); @@ -163,15 +1311,38 @@ class _AdminScreenState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(u.nombreCompleto, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + Text( + u.nombreCompleto, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), const SizedBox(height: 4), - Text(u.email, style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)), + Text( + u.email, + style: const TextStyle( + fontSize: 13, + color: AppTheme.textSecondary, + ), + ), Text(u.telefono, style: const TextStyle(fontSize: 13)), ], ), ), - IconButton(icon: const Icon(Icons.edit_outlined, color: AppTheme.primary), onPressed: () => _showUserForm(user: u)), - IconButton(icon: const Icon(Icons.delete_outline, color: AppTheme.danger), onPressed: () => _confirmDelete('usuario', () => setState(() => _usuarios.removeWhere((x) => x.id == u.id)))), + IconButton( + icon: const Icon(Icons.edit_outlined, color: AppTheme.primary), + onPressed: () => _showUserForm(user: u), + ), + IconButton( + icon: const Icon(Icons.delete_outline, color: AppTheme.danger), + onPressed: () => _confirmDelete( + 'usuario', + () => setState( + () => _usuarios.removeWhere((x) => x.id == u.id), + ), + ), + ), ], ), ); @@ -194,23 +1365,50 @@ class _AdminScreenState extends State children: [ Row( children: [ - Expanded(child: Text(r.nombre, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600))), - r.activa ? AppStatusBadge.green('Activa') : AppStatusBadge.gray('Inactiva'), + Expanded( + child: Text( + r.nombre, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + r.activa + ? AppStatusBadge.green('Activa') + : AppStatusBadge.gray('Inactiva'), ], ), const SizedBox(height: 6), - Text(r.zona, style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)), + Text( + r.zona, + style: const TextStyle( + fontSize: 13, + color: AppTheme.textSecondary, + ), + ), const SizedBox(height: 12), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - TextButton.icon(onPressed: () => _showRouteForm(route: r), icon: const Icon(Icons.edit_outlined, size: 18), label: const Text('Editar')), + TextButton.icon( + onPressed: () => _showRouteForm(route: r), + icon: const Icon(Icons.edit_outlined, size: 18), + label: const Text('Editar'), + ), const SizedBox(width: 8), TextButton.icon( - onPressed: () => _confirmDelete('ruta', () => setState(() => _rutas.removeWhere((x) => x.id == r.id))), + onPressed: () => _confirmDelete( + 'ruta', + () => setState( + () => _rutas.removeWhere((x) => x.id == r.id), + ), + ), icon: const Icon(Icons.delete_outline, size: 18), label: const Text('Eliminar'), - style: TextButton.styleFrom(foregroundColor: AppTheme.danger), + style: TextButton.styleFrom( + foregroundColor: AppTheme.danger, + ), ), ], ), @@ -230,31 +1428,62 @@ class _AdminScreenState extends State separatorBuilder: (_, i) => const SizedBox(height: 12), itemBuilder: (context, i) { final t = _camiones[i]; - final ruta = _rutas.firstWhere((r) => r.id == t.rutaId, orElse: () => const _AdminRoute(id: '', nombre: 'Sin ruta', zona: '')); + final ruta = _rutas.firstWhere( + (r) => r.id == t.rutaId, + orElse: () => const _AdminRoute(id: '', nombre: 'Sin ruta', zona: ''), + ); return AppCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - Expanded(child: Text(t.placas, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600))), + Expanded( + child: Text( + t.placas, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), t.status.badge, ], ), const SizedBox(height: 6), - Text('${t.modelo} · ${t.conductor}', style: const TextStyle(fontSize: 13)), - Text('Ruta: ${ruta.nombre}', style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)), + Text( + '${t.modelo} · ${t.conductor}', + style: const TextStyle(fontSize: 13), + ), + Text( + 'Ruta: ${ruta.nombre}', + style: const TextStyle( + fontSize: 13, + color: AppTheme.textSecondary, + ), + ), const SizedBox(height: 12), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - TextButton.icon(onPressed: () => _showTruckForm(truck: t), icon: const Icon(Icons.edit_outlined, size: 18), label: const Text('Editar')), + TextButton.icon( + onPressed: () => _showTruckForm(truck: t), + icon: const Icon(Icons.edit_outlined, size: 18), + label: const Text('Editar'), + ), const SizedBox(width: 8), TextButton.icon( - onPressed: () => _confirmDelete('camión', () => setState(() => _camiones.removeWhere((x) => x.id == t.id))), + onPressed: () => _confirmDelete( + 'camión', + () => setState( + () => _camiones.removeWhere((x) => x.id == t.id), + ), + ), icon: const Icon(Icons.delete_outline, size: 18), label: const Text('Eliminar'), - style: TextButton.styleFrom(foregroundColor: AppTheme.danger), + style: TextButton.styleFrom( + foregroundColor: AppTheme.danger, + ), ), ], ), @@ -265,7 +1494,16 @@ class _AdminScreenState extends State ); } - Widget _emptyState(String msg) => Center(child: Padding(padding: const EdgeInsets.all(24), child: Text(msg, textAlign: TextAlign.center, style: const TextStyle(fontSize: 15, color: AppTheme.textSecondary)))); + Widget _emptyState(String msg) => Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + msg, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 15, color: AppTheme.textSecondary), + ), + ), + ); // ── Confirmación de borrado ───────────────────────────────────────────────── void _confirmDelete(String tipo, VoidCallback onConfirm) { @@ -273,13 +1511,24 @@ class _AdminScreenState extends State context: context, builder: (ctx) => AlertDialog( backgroundColor: AppTheme.surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.radiusLg)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + ), title: Text('Eliminar $tipo'), content: Text('¿Deseas eliminar este $tipo?'), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), style: TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), child: const Text('Cancelar')), TextButton( - onPressed: () { onConfirm(); Navigator.pop(ctx); }, + onPressed: () => Navigator.pop(ctx), + style: TextButton.styleFrom( + foregroundColor: AppTheme.textSecondary, + ), + child: const Text('Cancelar'), + ), + TextButton( + onPressed: () { + onConfirm(); + Navigator.pop(ctx); + }, style: TextButton.styleFrom(foregroundColor: AppTheme.danger), child: const Text('Eliminar'), ), @@ -298,24 +1547,59 @@ class _AdminScreenState extends State context: context, builder: (ctx) => AlertDialog( backgroundColor: AppTheme.surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.radiusLg)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + ), title: Text(user == null ? 'Nuevo usuario' : 'Editar usuario'), content: SingleChildScrollView( - child: Column(mainAxisSize: MainAxisSize.min, children: [ - TextField(controller: nombreCtrl, decoration: const InputDecoration(labelText: 'Nombre')), - TextField(controller: apellidoCtrl, decoration: const InputDecoration(labelText: 'Apellido')), - TextField(controller: emailCtrl, decoration: const InputDecoration(labelText: 'Correo'), keyboardType: TextInputType.emailAddress), - TextField(controller: telefonoCtrl, decoration: const InputDecoration(labelText: 'Teléfono'), keyboardType: TextInputType.phone), - ]), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nombreCtrl, + decoration: const InputDecoration(labelText: 'Nombre'), + ), + TextField( + controller: apellidoCtrl, + decoration: const InputDecoration(labelText: 'Apellido'), + ), + TextField( + controller: emailCtrl, + decoration: const InputDecoration(labelText: 'Correo'), + keyboardType: TextInputType.emailAddress, + ), + TextField( + controller: telefonoCtrl, + decoration: const InputDecoration(labelText: 'Teléfono'), + keyboardType: TextInputType.phone, + ), + ], + ), ), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), style: TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), child: const Text('Cancelar')), + TextButton( + onPressed: () => Navigator.pop(ctx), + style: TextButton.styleFrom( + foregroundColor: AppTheme.textSecondary, + ), + child: const Text('Cancelar'), + ), TextButton( onPressed: () { - final nuevo = _AdminUser(id: user?.id ?? 'u-${DateTime.now().millisecondsSinceEpoch}', nombre: nombreCtrl.text.trim(), apellido: apellidoCtrl.text.trim(), email: emailCtrl.text.trim(), telefono: telefonoCtrl.text.trim()); + final nuevo = _AdminUser( + id: user?.id ?? 'u-${DateTime.now().millisecondsSinceEpoch}', + nombre: nombreCtrl.text.trim(), + apellido: apellidoCtrl.text.trim(), + email: emailCtrl.text.trim(), + telefono: telefonoCtrl.text.trim(), + ); setState(() { - if (user == null) { _usuarios.add(nuevo); } - else { final idx = _usuarios.indexWhere((x) => x.id == user.id); if (idx >= 0) _usuarios[idx] = nuevo; } + if (user == null) { + _usuarios.add(nuevo); + } else { + final idx = _usuarios.indexWhere((x) => x.id == user.id); + if (idx >= 0) _usuarios[idx] = nuevo; + } }); Navigator.pop(ctx); }, @@ -336,24 +1620,55 @@ class _AdminScreenState extends State builder: (ctx) => StatefulBuilder( builder: (ctx, setInner) => AlertDialog( backgroundColor: AppTheme.surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.radiusLg)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + ), title: Text(route == null ? 'Nueva ruta' : 'Editar ruta'), - content: Column(mainAxisSize: MainAxisSize.min, children: [ - TextField(controller: nombreCtrl, decoration: const InputDecoration(labelText: 'Nombre de ruta')), - TextField(controller: zonaCtrl, decoration: const InputDecoration(labelText: 'Zona')), - Row(children: [ - const Expanded(child: Text('Ruta activa')), - Switch.adaptive(value: activa, onChanged: (v) => setInner(() => activa = v)), - ]), - ]), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nombreCtrl, + decoration: const InputDecoration(labelText: 'Nombre de ruta'), + ), + TextField( + controller: zonaCtrl, + decoration: const InputDecoration(labelText: 'Zona'), + ), + Row( + children: [ + const Expanded(child: Text('Ruta activa')), + Switch.adaptive( + value: activa, + onChanged: (v) => setInner(() => activa = v), + ), + ], + ), + ], + ), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), style: TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), child: const Text('Cancelar')), + TextButton( + onPressed: () => Navigator.pop(ctx), + style: TextButton.styleFrom( + foregroundColor: AppTheme.textSecondary, + ), + child: const Text('Cancelar'), + ), TextButton( onPressed: () { - final nueva = _AdminRoute(id: route?.id ?? 'r-${DateTime.now().millisecondsSinceEpoch}', nombre: nombreCtrl.text.trim(), zona: zonaCtrl.text.trim(), activa: activa); + final nueva = _AdminRoute( + id: route?.id ?? 'r-${DateTime.now().millisecondsSinceEpoch}', + nombre: nombreCtrl.text.trim(), + zona: zonaCtrl.text.trim(), + activa: activa, + ); setState(() { - if (route == null) { _rutas.add(nueva); } - else { final idx = _rutas.indexWhere((x) => x.id == route.id); if (idx >= 0) _rutas[idx] = nueva; } + if (route == null) { + _rutas.add(nueva); + } else { + final idx = _rutas.indexWhere((x) => x.id == route.id); + if (idx >= 0) _rutas[idx] = nueva; + } }); Navigator.pop(ctx); }, @@ -371,43 +1686,90 @@ class _AdminScreenState extends State final modeloCtrl = TextEditingController(text: truck?.modelo); final conductorCtrl = TextEditingController(text: truck?.conductor); TruckStatus status = truck?.status ?? TruckStatus.disponible; - String selectedRuta = truck?.rutaId ?? (_rutas.isNotEmpty ? _rutas.first.id : ''); + String selectedRuta = + truck?.rutaId ?? (_rutas.isNotEmpty ? _rutas.first.id : ''); showDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, setInner) => AlertDialog( backgroundColor: AppTheme.surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.radiusLg)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + ), title: Text(truck == null ? 'Nuevo camión' : 'Editar camión'), content: SingleChildScrollView( - child: Column(mainAxisSize: MainAxisSize.min, children: [ - TextField(controller: placasCtrl, decoration: const InputDecoration(labelText: 'Placas')), - TextField(controller: modeloCtrl, decoration: const InputDecoration(labelText: 'Modelo')), - TextField(controller: conductorCtrl, decoration: const InputDecoration(labelText: 'Conductor')), - const SizedBox(height: 12), - DropdownButtonFormField( - value: selectedRuta.isEmpty ? null : selectedRuta, - decoration: const InputDecoration(labelText: 'Ruta'), - items: _rutas.map((r) => DropdownMenuItem(value: r.id, child: Text(r.nombre))).toList(), - onChanged: (v) { if (v != null) setInner(() => selectedRuta = v); }, - ), - const SizedBox(height: 12), - DropdownButtonFormField( - value: status, - decoration: const InputDecoration(labelText: 'Estatus'), - items: TruckStatus.values.map((s) => DropdownMenuItem(value: s, child: Text(s.label))).toList(), - onChanged: (v) { if (v != null) setInner(() => status = v); }, - ), - ]), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: placasCtrl, + decoration: const InputDecoration(labelText: 'Placas'), + ), + TextField( + controller: modeloCtrl, + decoration: const InputDecoration(labelText: 'Modelo'), + ), + TextField( + controller: conductorCtrl, + decoration: const InputDecoration(labelText: 'Conductor'), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + value: selectedRuta.isEmpty ? null : selectedRuta, + decoration: const InputDecoration(labelText: 'Ruta'), + items: _rutas + .map( + (r) => DropdownMenuItem( + value: r.id, + child: Text(r.nombre), + ), + ) + .toList(), + onChanged: (v) { + if (v != null) setInner(() => selectedRuta = v); + }, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + value: status, + decoration: const InputDecoration(labelText: 'Estatus'), + items: TruckStatus.values + .map( + (s) => DropdownMenuItem(value: s, child: Text(s.label)), + ) + .toList(), + onChanged: (v) { + if (v != null) setInner(() => status = v); + }, + ), + ], + ), ), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), style: TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), child: const Text('Cancelar')), + TextButton( + onPressed: () => Navigator.pop(ctx), + style: TextButton.styleFrom( + foregroundColor: AppTheme.textSecondary, + ), + child: const Text('Cancelar'), + ), TextButton( onPressed: () { - final nuevo = _AdminTruck(id: truck?.id ?? 't-${DateTime.now().millisecondsSinceEpoch}', placas: placasCtrl.text.trim(), modelo: modeloCtrl.text.trim(), conductor: conductorCtrl.text.trim(), status: status, rutaId: selectedRuta); + final nuevo = _AdminTruck( + id: truck?.id ?? 't-${DateTime.now().millisecondsSinceEpoch}', + placas: placasCtrl.text.trim(), + modelo: modeloCtrl.text.trim(), + conductor: conductorCtrl.text.trim(), + status: status, + rutaId: selectedRuta, + ); setState(() { - if (truck == null) { _camiones.add(nuevo); } - else { final idx = _camiones.indexWhere((x) => x.id == truck.id); if (idx >= 0) _camiones[idx] = nuevo; } + if (truck == null) { + _camiones.add(nuevo); + } else { + final idx = _camiones.indexWhere((x) => x.id == truck.id); + if (idx >= 0) _camiones[idx] = nuevo; + } }); Navigator.pop(ctx); }, diff --git a/recolecta_app/lib/features/admin/data/admin_service.dart b/recolecta_app/lib/features/admin/data/admin_service.dart new file mode 100644 index 0000000..23936de --- /dev/null +++ b/recolecta_app/lib/features/admin/data/admin_service.dart @@ -0,0 +1,191 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/network/api_client.dart'; +import '../models/admin_driver.dart'; +import '../models/admin_route.dart'; +import '../models/admin_unit.dart'; +import '../models/admin_user.dart'; + +final adminServiceProvider = Provider((ref) { + return AdminService(ref.read(apiClientProvider)); +}); + +class AdminService { + AdminService(this._dio); + final Dio _dio; + + // ── Users ─────────────────────────────────────────────────────────────────── + Future> listUsers() async { + final res = await _dio.get>('/admin/users'); + return (res.data ?? []) + .whereType() + .map((e) => AdminUserModel.fromJson(Map.from(e))) + .toList(); + } + + Future createUser({ + required String name, + required String password, + String? email, + String? phone, + String role = 'citizen', + }) async { + final res = await _dio.post>( + '/admin/users', + data: { + 'name': name, + 'password': password, + if (email != null && email.isNotEmpty) 'email': email, + if (phone != null && phone.isNotEmpty) 'phone': phone, + 'role': role, + }, + ); + return AdminUserModel.fromJson(res.data!); + } + + Future updateUser( + String id, { + String? name, + String? email, + String? role, + }) async { + final res = await _dio.patch>( + '/admin/users/$id', + data: { + if (name != null) 'name': name, + if (email != null) 'email': email, + if (role != null) 'role': role, + }, + ); + return AdminUserModel.fromJson(res.data!); + } + + Future deleteUser(String id) async { + await _dio.delete('/admin/users/$id'); + } + + // ── Routes ────────────────────────────────────────────────────────────────── + Future> listRoutes() async { + final res = await _dio.get>('/admin/routes'); + return (res.data ?? []) + .whereType() + .map((e) => AdminRouteModel.fromJson(Map.from(e))) + .toList(); + } + + Future createRoute({ + required String id, + String? name, + int? truckId, + String? turno, + String? status, + }) async { + final res = await _dio.post>( + '/admin/routes', + data: { + 'id': id, + if (name != null) 'name': name, + if (truckId != null) 'truck_id': truckId, + if (turno != null) 'turno': turno, + if (status != null) 'status': status, + }, + ); + return AdminRouteModel.fromJson(res.data!); + } + + Future updateRoute( + String id, { + String? name, + int? truckId, + String? turno, + String? status, + }) async { + final res = await _dio.patch>( + '/admin/routes/$id', + data: { + if (name != null) 'name': name, + if (truckId != null) 'truck_id': truckId, + if (turno != null) 'turno': turno, + if (status != null) 'status': status, + }, + ); + return AdminRouteModel.fromJson(res.data!); + } + + Future deleteRoute(String id) async { + await _dio.delete('/admin/routes/$id'); + } + + // ── Units ─────────────────────────────────────────────────────────────────── + Future> listUnits() async { + final res = await _dio.get>('/admin/units'); + return (res.data ?? []) + .whereType() + .map((e) => AdminUnitModel.fromJson(Map.from(e))) + .toList(); + } + + Future createUnit({ + required int id, + String? plate, + String status = 'active', + }) async { + final res = await _dio.post>( + '/admin/units', + data: {'id': id, if (plate != null) 'plate': plate, 'status': status}, + ); + return AdminUnitModel.fromJson(res.data!); + } + + Future updateUnit( + int id, { + String? plate, + String? status, + }) async { + final res = await _dio.patch>( + '/admin/units/$id', + data: { + if (plate != null) 'plate': plate, + if (status != null) 'status': status, + }, + ); + return AdminUnitModel.fromJson(res.data!); + } + + Future deleteUnit(int id) async { + await _dio.delete('/admin/units/$id'); + } + + // ── Drivers ───────────────────────────────────────────────────────────────── + Future> listDrivers() async { + final res = await _dio.get>('/admin/drivers'); + return (res.data ?? []) + .whereType() + .map((e) => AdminDriverModel.fromJson(Map.from(e))) + .toList(); + } + + Future createDriver({ + required String userId, + int? unitId, + }) async { + final res = await _dio.post>( + '/admin/drivers', + data: {'user_id': userId, if (unitId != null) 'unit_id': unitId}, + ); + return AdminDriverModel.fromJson(res.data!); + } + + Future updateDriver(String id, {int? unitId}) async { + final res = await _dio.patch>( + '/admin/drivers/$id', + data: {if (unitId != null) 'unit_id': unitId}, + ); + return AdminDriverModel.fromJson(res.data!); + } + + Future deleteDriver(String id) async { + await _dio.delete('/admin/drivers/$id'); + } +} diff --git a/recolecta_app/lib/features/admin/models/admin_driver.dart b/recolecta_app/lib/features/admin/models/admin_driver.dart new file mode 100644 index 0000000..5732df1 --- /dev/null +++ b/recolecta_app/lib/features/admin/models/admin_driver.dart @@ -0,0 +1,31 @@ +class AdminDriverModel { + final String id; + final String userId; + final String? userName; + final String? userEmail; + final int? unitId; + final String? plate; + + const AdminDriverModel({ + required this.id, + required this.userId, + this.userName, + this.userEmail, + this.unitId, + this.plate, + }); + + factory AdminDriverModel.fromJson(Map json) => + AdminDriverModel( + id: json['id'].toString(), + userId: json['user_id'].toString(), + userName: json['user_name'] as String?, + userEmail: json['user_email'] as String?, + unitId: (json['unit_id'] as num?)?.toInt(), + plate: json['plate'] as String?, + ); + + String get displayName => userName == null || userName!.trim().isEmpty + ? (userEmail ?? userId) + : userName!; +} diff --git a/recolecta_app/lib/features/admin/models/admin_route.dart b/recolecta_app/lib/features/admin/models/admin_route.dart new file mode 100644 index 0000000..ed597c8 --- /dev/null +++ b/recolecta_app/lib/features/admin/models/admin_route.dart @@ -0,0 +1,29 @@ +class AdminRouteModel { + final String id; + final String? name; + final int? truckId; + final String? turno; + final String status; + final int currentPositionId; + + const AdminRouteModel({ + required this.id, + this.name, + this.truckId, + this.turno, + this.status = 'pendiente', + this.currentPositionId = 1, + }); + + factory AdminRouteModel.fromJson(Map json) => + AdminRouteModel( + id: json['id'].toString(), + name: json['name'] as String?, + truckId: (json['truck_id'] as num?)?.toInt(), + turno: json['turno'] as String?, + status: (json['status'] as String?) ?? 'pendiente', + currentPositionId: (json['current_position_id'] as num?)?.toInt() ?? 1, + ); + + String get displayName => name == null || name!.trim().isEmpty ? id : name!; +} diff --git a/recolecta_app/lib/features/admin/models/admin_unit.dart b/recolecta_app/lib/features/admin/models/admin_unit.dart new file mode 100644 index 0000000..8fc4cee --- /dev/null +++ b/recolecta_app/lib/features/admin/models/admin_unit.dart @@ -0,0 +1,16 @@ +class AdminUnitModel { + final int id; + final String? plate; + final String status; + + const AdminUnitModel({required this.id, this.plate, this.status = 'active'}); + + factory AdminUnitModel.fromJson(Map json) => AdminUnitModel( + id: (json['id'] as num).toInt(), + plate: json['plate'] as String?, + status: (json['status'] as String?) ?? 'active', + ); + + String get displayPlate => + plate == null || plate!.trim().isEmpty ? '#$id' : plate!; +} diff --git a/recolecta_app/lib/features/admin/models/admin_user.dart b/recolecta_app/lib/features/admin/models/admin_user.dart new file mode 100644 index 0000000..d5a0084 --- /dev/null +++ b/recolecta_app/lib/features/admin/models/admin_user.dart @@ -0,0 +1,34 @@ +class AdminUserModel { + final String id; + final String? name; + final String? email; + final String? phone; + final String role; + + const AdminUserModel({ + required this.id, + this.name, + this.email, + this.phone, + this.role = 'citizen', + }); + + String get displayName => + (name == null || name!.trim().isEmpty) ? (email ?? phone ?? id) : name!; + + String get initials { + final source = displayName.trim(); + if (source.isEmpty) return '?'; + final parts = source.split(RegExp(r'\s+')); + if (parts.length == 1) return parts.first.substring(0, 1).toUpperCase(); + return (parts[0].substring(0, 1) + parts[1].substring(0, 1)).toUpperCase(); + } + + factory AdminUserModel.fromJson(Map json) => AdminUserModel( + id: json['id'].toString(), + name: json['name'] as String?, + email: json['email'] as String?, + phone: json['phone'] as String?, + role: (json['role'] as String?) ?? 'citizen', + ); +} diff --git a/recolecta_app/lib/features/admin/providers/admin_providers.dart b/recolecta_app/lib/features/admin/providers/admin_providers.dart new file mode 100644 index 0000000..50be3c1 --- /dev/null +++ b/recolecta_app/lib/features/admin/providers/admin_providers.dart @@ -0,0 +1,23 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/admin_service.dart'; +import '../models/admin_driver.dart'; +import '../models/admin_route.dart'; +import '../models/admin_unit.dart'; +import '../models/admin_user.dart'; + +final adminUsersProvider = FutureProvider>((ref) { + return ref.read(adminServiceProvider).listUsers(); +}); + +final adminRoutesProvider = FutureProvider>((ref) { + return ref.read(adminServiceProvider).listRoutes(); +}); + +final adminUnitsProvider = FutureProvider>((ref) { + return ref.read(adminServiceProvider).listUnits(); +}); + +final adminDriversProvider = FutureProvider>((ref) { + return ref.read(adminServiceProvider).listDrivers(); +}); diff --git a/recolecta_app/lib/features/auth/login_page.dart b/recolecta_app/lib/features/auth/login_page.dart index 603afb0..408b8fd 100644 --- a/recolecta_app/lib/features/auth/login_page.dart +++ b/recolecta_app/lib/features/auth/login_page.dart @@ -1,12 +1,13 @@ +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:dio/dio.dart'; +import '../../core/models/auth_state.dart'; +import '../../core/services/auth_controller.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_widgets.dart'; -import '../../core/services/auth_controller.dart'; -import '../../core/models/auth_state.dart'; +import 'widgets/video_mascot.dart'; class LoginPage extends ConsumerStatefulWidget { const LoginPage({super.key}); @@ -30,23 +31,18 @@ class _LoginPageState extends ConsumerState { ) { if (!mounted) return; if (next is AsyncError) { - String errorMessage = 'Ocurrió un error inesperado'; final error = next.error; - + String msg = 'Ocurrió un error inesperado'; if (error is DioException) { - if (error.response?.data != null && error.response?.data is Map) { - errorMessage = - error.response!.data['detail'] ?? 'Credenciales inválidas'; - } else { - errorMessage = 'Error de conexión con el servidor'; - } + msg = (error.response?.data is Map) + ? error.response!.data['detail'] ?? 'Credenciales inválidas' + : 'Error de conexión con el servidor'; } else { - errorMessage = error.toString(); + msg = error.toString(); } - ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(errorMessage), + content: Text(msg), backgroundColor: AppTheme.danger, behavior: SnackBarBehavior.floating, ), @@ -72,171 +68,189 @@ class _LoginPageState extends ConsumerState { @override Widget build(BuildContext context) { final loading = ref.watch(authControllerProvider).isLoading; + final screenH = MediaQuery.of(context).size.height; return Scaffold( backgroundColor: AppTheme.background, - appBar: AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - iconTheme: const IconThemeData(color: AppTheme.textPrimary), - title: const Text( - 'Iniciar sesión', - style: TextStyle(color: AppTheme.textPrimary, fontSize: 16), - ), - ), - body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 8), + body: Column( + children: [ + // ── Cabecera verde con mascota ───────────────────────────── + _GreenHeader(height: screenH * 0.38), - // ── Encabezado ────────────────────────────────────────── - Row( + // ── Formulario ───────────────────────────────────────────── + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 28, 24, 24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: AppTheme.primaryLight, - borderRadius: BorderRadius.circular(AppTheme.radiusMd), - ), - child: const Icon( - Icons.delete_outline_rounded, - color: AppTheme.primary, - size: 26, + AppFormField( + label: 'Correo electrónico', + hint: 'tu@correo.com', + controller: _emailCtrl, + keyboardType: TextInputType.emailAddress, + validator: (v) => (v == null || v.trim().isEmpty) + ? 'Ingresa tu correo' + : null, + ), + const SizedBox(height: 16), + AppFormField( + label: 'Contraseña', + hint: '••••••••', + controller: _passCtrl, + obscureText: _obscurePass, + validator: (v) => (v == null || v.length < 6) + ? 'Mínimo 6 caracteres' + : null, + suffix: IconButton( + icon: Icon( + _obscurePass + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + size: 18, + color: AppTheme.textSecondary, + ), + onPressed: () => + setState(() => _obscurePass = !_obscurePass), ), ), - const SizedBox(width: 14), - const Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Recolecta', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w700, - color: AppTheme.textPrimary, - ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () {}, + style: TextButton.styleFrom( + foregroundColor: AppTheme.primary, + padding: EdgeInsets.zero, + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), - Text( - 'Bienvenido de nuevo', - style: TextStyle( - fontSize: 13, - color: AppTheme.textSecondary, - ), + child: const Text( + '¿Olvidaste tu contraseña?', + style: TextStyle(fontSize: 13), ), - ], + ), + ), + const SizedBox(height: 24), + SizedBox( + height: 52, + child: ElevatedButton( + onPressed: loading ? null : _submit, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: loading + ? const SizedBox( + key: ValueKey('loading'), + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text( + 'Ingresar', + key: ValueKey('text'), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + const SizedBox(height: 32), + Center( + child: Wrap( + alignment: WrapAlignment.center, + children: [ + const Text( + '¿No tienes cuenta? ', + style: TextStyle( + fontSize: 13, + color: AppTheme.textSecondary, + ), + ), + GestureDetector( + onTap: () => context.go('/register'), + child: const Text( + 'Regístrate', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppTheme.primary, + ), + ), + ), + ], + ), ), ], ), + ), + ), + ), + ], + ), + ); + } +} - const SizedBox(height: 32), +// ── Cabecera con gradiente verde y mascota ─────────────────────────────────── - // ── Formulario ────────────────────────────────────────── - AppFormField( - label: 'Correo electrónico', - hint: 'tu@correo.com', - controller: _emailCtrl, - keyboardType: TextInputType.emailAddress, - validator: (v) => (v == null || v.trim().isEmpty) - ? 'Ingresa tu correo' - : null, - ), - const SizedBox(height: 16), - AppFormField( - label: 'Contraseña', - hint: '••••••••', - controller: _passCtrl, - obscureText: _obscurePass, - validator: (v) => (v == null || v.length < 6) - ? 'Mínimo 6 caracteres' - : null, - suffix: IconButton( - icon: Icon( - _obscurePass - ? Icons.visibility_outlined - : Icons.visibility_off_outlined, - size: 18, - color: AppTheme.textSecondary, - ), - onPressed: () => - setState(() => _obscurePass = !_obscurePass), - ), - ), +class _GreenHeader extends StatelessWidget { + final double height; + const _GreenHeader({required this.height}); - const SizedBox(height: 10), - Align( - alignment: Alignment.centerRight, - child: TextButton( - onPressed: () {}, - style: TextButton.styleFrom( - foregroundColor: AppTheme.primary, - ), - child: const Text( - '¿Olvidaste tu contraseña?', - style: TextStyle(fontSize: 13), + @override + Widget build(BuildContext context) { + return ClipPath( + clipper: _WaveClipper(), + child: Container( + height: height, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + stops: [0.0, 0.6, 1.0], + colors: [Color(0xFF0A4A38), Color(0xFF0F6E56), Color(0xFF1D9E75)], + ), + ), + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 8), + const VideoMascot(size: 108), + const SizedBox(height: 16), + const Text( + 'RecolectApp', + style: TextStyle( + fontSize: 30, + fontWeight: FontWeight.w800, + color: Colors.white, + letterSpacing: -0.8, ), ), - ), - - const SizedBox(height: 24), - - // ── Botón ─────────────────────────────────────────────── - SizedBox( - width: double.infinity, - height: 52, - child: ElevatedButton( - onPressed: loading ? null : _submit, - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: loading - ? const SizedBox( - key: ValueKey('loading'), - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Text('Ingresar', key: ValueKey('text')), + const SizedBox(height: 4), + Text( + 'Bienvenido de nuevo', + style: TextStyle( + fontSize: 14, + color: Colors.white.withValues(alpha: 0.82), + fontWeight: FontWeight.w400, ), ), - ), - - const SizedBox(height: 36), - - // ── Crear cuenta ──────────────────────────────────────── - Center( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - '¿No tienes cuenta? ', - style: TextStyle( - fontSize: 13, - color: AppTheme.textSecondary, - ), - ), - GestureDetector( - onTap: () => context.go('/register'), - child: const Text( - 'Regístrate', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: AppTheme.primary, - ), - ), - ), - ], - ), - ), - ], + const SizedBox(height: 28), + ], + ), ), ), ), @@ -244,3 +258,29 @@ class _LoginPageState extends ConsumerState { ); } } + +class _WaveClipper extends CustomClipper { + @override + Path getClip(Size size) { + final path = Path(); + path.lineTo(0, size.height - 36); + path.quadraticBezierTo( + size.width * 0.25, + size.height, + size.width * 0.5, + size.height - 18, + ); + path.quadraticBezierTo( + size.width * 0.75, + size.height - 36, + size.width, + size.height - 10, + ); + path.lineTo(size.width, 0); + path.close(); + return path; + } + + @override + bool shouldReclip(_WaveClipper old) => false; +} diff --git a/recolecta_app/lib/features/auth/register_page.dart b/recolecta_app/lib/features/auth/register_page.dart index 0f2545b..465dbeb 100644 --- a/recolecta_app/lib/features/auth/register_page.dart +++ b/recolecta_app/lib/features/auth/register_page.dart @@ -40,6 +40,7 @@ class _RegisterPageState extends ConsumerState { final _step1FormKey = GlobalKey(); // Paso 1 + final _nameCtrl = TextEditingController(); final _emailCtrl = TextEditingController(); final _telefonoCtrl = TextEditingController(); final _passCtrl = TextEditingController(); @@ -91,6 +92,7 @@ class _RegisterPageState extends ConsumerState { @override void dispose() { _pageController.dispose(); + _nameCtrl.dispose(); _emailCtrl.dispose(); _telefonoCtrl.dispose(); _passCtrl.dispose(); @@ -201,77 +203,19 @@ class _RegisterPageState extends ConsumerState { FocusScope.of(context).unfocus(); // Cierra el teclado } - Future _register() async { - if (_calleCtrl.text.trim().isEmpty || _selectedColonia == null) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Ingresa tu calle y selecciona una colonia'), - behavior: SnackBarBehavior.floating, - ), - ); - return; - } - - final phoneDigits = _telefonoCtrl.text.replaceAll(RegExp(r'\D'), ''); - final phone = phoneDigits.isNotEmpty ? '+52$phoneDigits' : ''; - - final calle = _calleCtrl.text.trim(); - final colonia = _selectedColonia!.nombre; - final lat = _selectedLocation?.latitude; - final lng = _selectedLocation?.longitude; - - try { - await ref - .read(authControllerProvider.notifier) - .register( - email: _emailCtrl.text.trim(), - phone: phone, - password: _passCtrl.text, - addressCalle: calle, - addressColonia: colonia, - addressLabel: 'Mi Casa', - addressLat: lat, - addressLng: lng, - ); - - // Guardado silencioso de la dirección tras un registro exitoso - _postAddressInBackground(calle, colonia, lat, lng); - } catch (_) { - // El error ya es manejado por el listener y muestra el SnackBar - } - } - - Future _postAddressInBackground( - String calle, - String colonia, - double? lat, - double? lng, - ) async { - try { - const storage = FlutterSecureStorage(); - await Future.delayed( - const Duration(milliseconds: 800), - ); // Esperar a que se guarde el JWT - final token = await storage.read(key: authTokenStorageKey) ?? ''; - - if (token.isNotEmpty) { - final dio = Dio( - BaseOptions( - baseUrl: const String.fromEnvironment( - 'API_BASE_URL', - defaultValue: 'http://localhost:8000', - ), - headers: {'Authorization': 'Bearer $token'}, - ), - ); - await dio.post( - '/addresses', - data: {'label': 'Mi Casa', 'calle': calle, 'colonia': colonia}, - ); - } - } catch (e) { - debugPrint('Aviso: No se pudo crear la dirección: $e'); - } + void _onRegister() { + final auth = ref.read(authControllerProvider.notifier); + auth.register( + name: _nameCtrl.text, + email: _emailCtrl.text, + phone: _telefonoCtrl.text, + password: _passCtrl.text, + addressCalle: _calleCtrl.text, + addressColonia: _selectedColonia?.nombre, + addressLabel: _tipoInmueble, + addressLat: _selectedLocation?.latitude, + addressLng: _selectedLocation?.longitude, + ); } @override @@ -299,35 +243,431 @@ class _RegisterPageState extends ConsumerState { controller: _pageController, physics: const NeverScrollableScrollPhysics(), children: [ - _Step1( - formKey: _step1FormKey, - emailCtrl: _emailCtrl, - telefonoCtrl: _telefonoCtrl, - passCtrl: _passCtrl, - obscurePass: _obscurePass, - onTogglePass: () => setState(() => _obscurePass = !_obscurePass), - onNext: _nextPage, + _buildStep1(context), + _buildStep2(context, loading, coloniasList), + ], + ), + bottomNavigationBar: _buildBottomControls(context, loading), + ); + } + + Widget _buildStep1(BuildContext context) { + return Form( + key: _step1FormKey, + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 40), + children: [ + const Text( + 'Crea tu cuenta', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + color: AppTheme.textPrimary, + ), ), - _Step2( - mapController: _mapController, - cpCtrl: _cpCtrl, - calleCtrl: _calleCtrl, - selectedColonia: _selectedColonia, - selectedLocation: _selectedLocation, - tipoInmueble: _tipoInmueble, - whatsappNotif: _whatsappNotif, - loading: loading, - onTipoChanged: (v) => setState(() => _tipoInmueble = v), - onCPChanged: (v) => _validarCP(v, coloniasList), - onLocationChanged: _fetchStreetName, - onWhatsappChanged: (v) => - setState(() => _whatsappNotif = v ?? false), - onRegister: _register, + const SizedBox(height: 8), + const Text( + 'Ingresa tus datos para registrarte.', + style: TextStyle(fontSize: 15, color: AppTheme.textSecondary), + ), + const SizedBox(height: 28), + AppFormField( + controller: _nameCtrl, + label: 'Nombre completo', + validator: (val) => + val!.isEmpty ? 'Ingresa tu nombre completo' : null, + ), + const SizedBox(height: 16), + AppFormField( + controller: _emailCtrl, + label: 'Correo electrónico', + hint: 'tu@correo.com', + keyboardType: TextInputType.emailAddress, + validator: (v) { + if (v == null || v.trim().isEmpty) return 'Ingresa tu correo'; + final emailRegex = RegExp(r'^[^@]+@[^@]+\.[^@]+'); + if (!emailRegex.hasMatch(v.trim())) + return 'Ingresa un correo válido'; + return null; + }, + ), + const SizedBox(height: 14), + _PhoneField(controller: _telefonoCtrl), + const SizedBox(height: 14), + AppFormField( + label: 'Contraseña', + hint: '••••••••', + controller: _passCtrl, + obscureText: _obscurePass, + validator: (v) { + if (v == null || v.isEmpty) return 'Ingresa una contraseña'; + if (v.length < 6) return 'Mínimo 6 caracteres'; + return null; + }, + suffix: IconButton( + icon: Icon( + _obscurePass + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + size: 18, + color: AppTheme.textSecondary, + ), + onPressed: () => setState(() => _obscurePass = !_obscurePass), + ), ), ], ), ); } + + Widget _buildStep2( + BuildContext context, + bool loading, + List coloniasList, + ) { + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + AppFormCard( + icon: Icons.home_outlined, + title: 'Dirección de tu casa', + child: Column( + children: [ + const Text( + 'Tipo de inmueble', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppTheme.textSecondary, + ), + ), + Row( + children: [ + Expanded( + child: Material( + color: Colors.transparent, + child: RadioListTile( + title: const Text( + 'Casa', + style: TextStyle(fontSize: 14), + ), + value: 'Casa', + groupValue: _tipoInmueble, + onChanged: (v) => setState(() => _tipoInmueble = v!), + ), + ), + ), + Expanded( + child: Material( + color: Colors.transparent, + child: RadioListTile( + title: const Text( + 'Negocio', + style: TextStyle(fontSize: 14), + ), + value: 'Negocio', + groupValue: _tipoInmueble, + onChanged: (v) => setState(() => _tipoInmueble = v!), + ), + ), + ), + ], + ), + const SizedBox(height: 8), + AppFormField( + label: 'Código Postal', + hint: 'Ej. 38000', + controller: _cpCtrl, + keyboardType: TextInputType.number, + onChanged: (v) => _validarCP(v, coloniasList), + ), + + if (_selectedColonia != null) ...[ + const SizedBox(height: 14), + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppTheme.primaryLight.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + border: Border.all(color: AppTheme.primaryMid), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.check_circle_outline, + color: AppTheme.primary, + size: 18, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Colonia: ${_selectedColonia!.nombre}', + style: const TextStyle( + fontWeight: FontWeight.w600, + color: AppTheme.primaryDark, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Horario ${_selectedColonia!.turno?.toLowerCase() ?? 'asignado'}', + style: const TextStyle( + fontSize: 13, + color: AppTheme.textPrimary, + ), + ), + Text( + _selectedColonia!.horarioEstimado ?? + 'Sin horario especificado', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + ], + ), + ), + const SizedBox(height: 14), + AppFormField( + label: 'Calle y número', + hint: 'Av. Insurgentes 245', + controller: _calleCtrl, + ), + const SizedBox(height: 16), + const Text( + 'Toca el mapa para ubicar tu casa exacta:', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 8), + Container( + height: 200, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + border: Border.all(color: AppTheme.border), + ), + clipBehavior: Clip.hardEdge, + child: FlutterMap( + mapController: _mapController, + options: MapOptions( + initialCenter: + _selectedLocation ?? + const LatLng(20.5222, -100.8123), + initialZoom: 15.0, + onTap: (_, latlng) => _fetchStreetName(latlng), + ), + children: [ + TileLayer( + urlTemplate: + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.onlineshack.recolecta', + ), + if (_selectedLocation != null) + MarkerLayer( + markers: [ + Marker( + point: _selectedLocation!, + width: 40, + height: 40, + child: const Icon( + Icons.location_on, + color: AppTheme.danger, + size: 40, + ), + ), + ], + ), + ], + ), + ), + ] else ...[ + const SizedBox(height: 24), + const Center( + child: Text( + 'Ingresa un código postal con servicio\npara asignar tu colonia.', + textAlign: TextAlign.center, + style: TextStyle( + color: AppTheme.textSecondary, + fontSize: 13, + ), + ), + ), + ], + ], + ), + ), + const SizedBox(height: 16), + + // ── Sección OCR (Privacidad por diseño) ── + AppFormCard( + icon: Icons.document_scanner_outlined, + title: 'Verificación de Domicilio', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Para prevenir abusos, requerimos validar tu dirección con un recibo (luz o agua). ' + 'Por privacidad, la imagen será borrada inmediatamente después de la lectura.', + style: TextStyle( + fontSize: 13, + color: AppTheme.textSecondary, + height: 1.4, + ), + ), + const SizedBox(height: 14), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + icon: const Icon( + Icons.upload_file, + color: AppTheme.primary, + ), + label: const Text( + 'Escanear recibo (OCR)', + style: TextStyle(color: AppTheme.primary), + ), + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Abriendo cámara... (Próximamente)'), + ), + ); + }, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // ── Sección WhatsApp ── + AppFormCard( + icon: Icons.chat_outlined, + title: 'Notificaciones Externas', + child: Column( + children: [ + Material( + color: Colors.transparent, + child: CheckboxListTile( + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + activeColor: AppTheme.primary, + value: _whatsappNotif, + onChanged: (v) => + setState(() => _whatsappNotif = v ?? false), + title: const Text( + 'Recibir alertas del camión vía WhatsApp (Próximamente)', + style: TextStyle( + fontSize: 14, + color: AppTheme.textPrimary, + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 28), + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: loading ? null : _onRegister, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: loading + ? const SizedBox( + key: ValueKey('loading'), + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Row( + key: ValueKey('text'), + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check, size: 18), + SizedBox(width: 8), + Flexible( + child: Text( + 'Registrarme', + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ), + const SizedBox(height: 16), + const Center( + child: Text( + 'Al registrarte aceptas los Términos de Servicio\ny la Política de Privacidad.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + color: AppTheme.textSecondary, + height: 1.5, + ), + ), + ), + ], + ), + ); + } + + Widget _buildBottomControls(BuildContext context, bool isLoading) { + return Container( + padding: const EdgeInsets.all( + 20, + ).copyWith(bottom: MediaQuery.of(context).padding.bottom + 20), + decoration: const BoxDecoration( + color: AppTheme.background, + border: Border(top: BorderSide(color: AppTheme.border, width: 0.5)), + ), + child: _currentPage == 0 + ? SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: _nextPage, + child: const Text('Continuar'), + ), + ) + : SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: isLoading ? null : _onRegister, + child: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text('Crear mi cuenta'), + ), + ), + ); + } } // ── Indicador de pasos ──────────────────────────────────────────────────────── @@ -794,17 +1134,17 @@ class _Step2 extends StatelessWidget { color: Colors.white, ), ) - : const Row( + : const FittedBox( key: ValueKey('text'), - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.check, size: 18), - SizedBox(width: 8), - Flexible( - child: Text('Registrarme', - overflow: TextOverflow.ellipsis), - ), - ], + fit: BoxFit.scaleDown, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.check, size: 18), + SizedBox(width: 8), + Text('Registrarme'), + ], + ), ), ), ), diff --git a/recolecta_app/lib/features/auth/widgets/video_mascot.dart b/recolecta_app/lib/features/auth/widgets/video_mascot.dart new file mode 100644 index 0000000..bd43d42 --- /dev/null +++ b/recolecta_app/lib/features/auth/widgets/video_mascot.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +class VideoMascot extends StatelessWidget { + final double size; + + const VideoMascot({super.key, this.size = 108}); + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.1), + ), + clipBehavior: Clip.hardEdge, + // Cargamos el archivo como GIF + child: Image.asset( + 'assets/animations/blink_saludo.gif', + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + // Plan B: si el archivo no existe o hay error, mostramos la huellita + return const Center( + child: Icon(Icons.pets, color: Colors.white, size: 48), + ); + }, + ), + ); + } +} diff --git a/recolecta_app/lib/features/eta/eta_provider.dart b/recolecta_app/lib/features/eta/eta_provider.dart new file mode 100644 index 0000000..46d54bc --- /dev/null +++ b/recolecta_app/lib/features/eta/eta_provider.dart @@ -0,0 +1,49 @@ +// lib/features/eta/eta_provider.dart +// Riverpod AsyncNotifier: carga ETA al abrir la app y al recibir push FCM. +// No hace polling continuo. + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:recolecta_app/features/eta/eta_model.dart'; +import 'package:recolecta_app/features/eta/eta_service.dart'; + +// ────────────────────────────────────────── +// Provider del addressId activo del ciudadano +// (se puebla en el provider de auth/session) +// ────────────────────────────────────────── +class ActiveAddressIdNotifier extends Notifier { + @override + String? build() => null; +} + +final activeAddressIdProvider = + NotifierProvider( + ActiveAddressIdNotifier.new, + ); + +// ────────────────────────────────────────── +// AsyncNotifier principal de ETA +// ────────────────────────────────────────── +class EtaNotifier extends AsyncNotifier { + @override + Future build() async { + final addressId = ref.watch(activeAddressIdProvider); + if (addressId == null) { + throw Exception('No hay domicilio verificado'); + } + return ref.read(etaServiceProvider).fetchEta(addressId); + } + + /// Llamar desde la UI (botón refrescar) o desde el handler de FCM. + Future refresh() async { + state = const AsyncLoading(); + final addressId = ref.read(activeAddressIdProvider); + if (addressId == null) return; + state = await AsyncValue.guard( + () => ref.read(etaServiceProvider).fetchEta(addressId), + ); + } +} + +final etaProvider = AsyncNotifierProvider( + EtaNotifier.new, +); diff --git a/recolecta_app/lib/features/eta/eta_screen.dart b/recolecta_app/lib/features/eta/eta_screen.dart index f8faace..6e5e3a4 100644 --- a/recolecta_app/lib/features/eta/eta_screen.dart +++ b/recolecta_app/lib/features/eta/eta_screen.dart @@ -1,3 +1,14 @@ +// lib/features/eta/eta_screen.dart +// Vista principal del ciudadano: ETA con mapa de domicilio y progreso de ruta. +// Fusiona eta_screen.dart (doc-1) + eta_screen_v2.dart (doc-2). +// Orden visual: +// 1. Hero card (estado + ventana horaria) +// 2. Domicilio registrado +// 3. ProgressSteps ← nuevo: justo debajo del mapa/dirección +// 4. PreventionBanner +// 5. FCM badge +// 6. Horario semanal + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -5,43 +16,13 @@ import 'package:go_router/go_router.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_widgets.dart'; import '../../core/network/api_client.dart'; +import '../notifications/notification_service.dart'; +import '../../shared/widgets/prevention_banner.dart'; +import '../../shared/widgets/progress_steps.dart'; -// ── Provider de ETA ─────────────────────────────────────────────────────────── -final etaProvider = FutureProvider.autoDispose<_EtaResult>((ref) async { - final dio = ref.read(apiClientProvider); - - final addressesResp = await dio.get('/addresses'); - final raw = addressesResp.data; - - List items = const []; - if (raw is List) { - items = raw; - } else if (raw is Map && raw['data'] is List) { - items = raw['data'] as List; - } else if (raw is Map && raw['addresses'] is List) { - items = raw['addresses'] as List; - } - - if (items.isEmpty) { - return const _EtaResult.noAddress(); - } - - final addressId = items.first['id'] as String; - final etaResp = await dio.get( - '/eta', - queryParameters: {'address_id': addressId}, - ); - - final data = etaResp.data as Map; - return _EtaResult( - mensaje: data['mensaje'] as String? ?? '', - status: data['status'] as String? ?? '', - direccion: items.first['calle'] as String? ?? '', - colonia: items.first['colonia'] as String? ?? '', - hasAddress: true, - ); -}); - +// ───────────────────────────────────────────────────────────────────────────── +// Modelo de resultado ETA +// ───────────────────────────────────────────────────────────────────────────── class _EtaResult { final String mensaje; final String status; @@ -58,211 +39,446 @@ class _EtaResult { }); const _EtaResult.noAddress() - : mensaje = '', - status = '', - direccion = '', - colonia = '', - hasAddress = false; + : mensaje = '', + status = '', + direccion = '', + colonia = '', + hasAddress = false; + + // ── Utilidades derivadas ─────────────────────────────────────────────────── + + bool get isCompleted => status == 'completada'; + bool get isNearby => + mensaje.contains('15 minutos') || mensaje.contains('Está atendiendo'); double get progreso { - if (mensaje.contains('15 minutos') || mensaje.contains('Está atendiendo')) { - return 0.85; - } - if (mensaje.contains('finalizado')) return 1.0; + if (isNearby) return 0.85; + if (isCompleted) return 1.0; return 0.35; } + /// Í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 (isCompleted) return 4; + if (isNearby) return 3; + if (status == 'en_ruta') return 2; + return 1; + } + String get etiquetaEstado { - if (status == 'completada') return 'Finalizado'; + if (isCompleted) return 'Finalizado'; if (status == 'en_ruta') return 'En ruta'; return 'Pendiente'; } } -// ── Pantalla ETA ────────────────────────────────────────────────────────────── -class EtaScreen extends ConsumerWidget { +// ───────────────────────────────────────────────────────────────────────────── +// Provider de ETA +// ───────────────────────────────────────────────────────────────────────────── +class _EtaNotifier extends AsyncNotifier<_EtaResult> { + @override + Future<_EtaResult> build() => _fetch(); + + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(_fetch); + } + + Future<_EtaResult> _fetch() async { + final dio = ref.read(apiClientProvider); + + final addressesResp = await dio.get('/addresses'); + final raw = addressesResp.data; + + List items = const []; + if (raw is List) { + items = raw; + } else if (raw is Map && raw['data'] is List) { + items = raw['data'] as List; + } else if (raw is Map && raw['addresses'] is List) { + items = raw['addresses'] as List; + } + + if (items.isEmpty) return const _EtaResult.noAddress(); + + final addressId = items.first['id'] as String; + final etaResp = await dio.get( + '/eta', + queryParameters: {'address_id': addressId}, + ); + + final data = etaResp.data as Map; + return _EtaResult( + mensaje: data['mensaje'] as String? ?? '', + status: data['status'] as String? ?? '', + direccion: items.first['calle'] as String? ?? '', + colonia: items.first['colonia'] as String? ?? '', + hasAddress: true, + ); + } +} + +final etaProvider = AsyncNotifierProvider.autoDispose<_EtaNotifier, _EtaResult>( + _EtaNotifier.new, +); + +// Expone el routeId activo (se puebla desde el provider de sesión/domicilio) +class ActiveRouteIdNotifier extends Notifier { + @override + String? build() => null; +} + +final activeRouteIdProvider = NotifierProvider( + ActiveRouteIdNotifier.new, +); + +// ───────────────────────────────────────────────────────────────────────────── +// Pantalla principal +// ───────────────────────────────────────────────────────────────────────────── +class EtaScreen extends ConsumerStatefulWidget { const EtaScreen({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _EtaScreenState(); +} + +class _EtaScreenState extends ConsumerState + with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + // Refresca al recibir push FCM (RUTA_PROXIMITY, ROUTE_START, etc.) + NotificationService.onFcmMessage.addListener(_onPush); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + NotificationService.onFcmMessage.removeListener(_onPush); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + ref.read(etaProvider.notifier).refresh(); + } + } + + void _onPush() => ref.read(etaProvider.notifier).refresh(); + + @override + Widget build(BuildContext context) { final etaAsync = ref.watch(etaProvider); return Scaffold( backgroundColor: AppTheme.background, appBar: AppBar( - title: const Text('Estado del camión'), + title: const Text('Mi recolección'), actions: [ IconButton( - icon: const Icon(Icons.refresh), + icon: const Icon(Icons.refresh_rounded), tooltip: 'Actualizar', - onPressed: () => ref.invalidate(etaProvider), + onPressed: () => ref.read(etaProvider.notifier).refresh(), ), ], ), body: etaAsync.when( loading: () => const _EtaLoading(), - error: (error, _) => _EtaError( - error: error.toString(), - onRetry: () => ref.invalidate(etaProvider), + error: (e, _) => _EtaError( + error: e.toString(), + onRetry: () => ref.read(etaProvider.notifier).refresh(), ), data: (result) => result.hasAddress ? _EtaContent(result: result) - : _NoAddressState( - onAdd: () => context.go('/addresses/new'), - ), + : _NoAddressState(onAdd: () => context.go('/addresses/new')), ), ); } } -// ── Contenido ETA ───────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── +// Contenido principal +// ───────────────────────────────────────────────────────────────────────────── class _EtaContent extends StatelessWidget { final _EtaResult result; const _EtaContent({required this.result}); @override Widget build(BuildContext context) { - return ListView( - padding: const EdgeInsets.all(16), - children: [ - // ── Tarjeta de estado principal ──────────────────────────────── - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: AppTheme.primaryLight, - borderRadius: BorderRadius.circular(AppTheme.radiusLg), - border: Border.all(color: AppTheme.primaryMid), - boxShadow: AppTheme.softShadow, + return RefreshIndicator( + onRefresh: () => ProviderScope.containerOf( + context, + ).read(etaProvider.notifier).refresh(), + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + children: [ + // ── 1. Hero card ──────────────────────────────────────────────── + _EtaHeroCard(result: result), + const SizedBox(height: 16), + + // ── 2. Domicilio registrado ───────────────────────────────────── + AppInfoRow( + icon: Icons.home_outlined, + label: 'Col. ${result.colonia}', + value: result.direccion.isEmpty ? 'Mi domicilio' : result.direccion, + trailing: AppStatusBadge.green('Activo'), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + const SizedBox(height: 12), + + // ── 3. Pasos de progreso (justo debajo del domicilio) ─────────── + ProgressSteps(stepIndex: result.stepIndex), + const SizedBox(height: 12), + + // ── 4. Banner de prevención ───────────────────────────────────── + const PreventionBanner(), + const SizedBox(height: 12), + + // ── 5. Badge de suscripción FCM ───────────────────────────────── + const _FcmStatusBadge(), + const SizedBox(height: 16), + + // ── 6. Horario semanal ────────────────────────────────────────── + AppSectionTitle(title: 'Horario del camión'), + _HorarioCard(), + const SizedBox(height: 24), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Hero card: estado + ventana horaria + barra de progreso +// ───────────────────────────────────────────────────────────────────────────── +class _EtaHeroCard extends StatelessWidget { + final _EtaResult result; + const _EtaHeroCard({required this.result}); + + Color _bgColor(BuildContext context) { + final cs = Theme.of(context).colorScheme; + if (result.isCompleted) return cs.surfaceContainerHighest; + if (result.isNearby) return const Color(0xFFFFF8E1); // amber-50 + return const Color(0xFFE1F5EE); // teal-50 + } + + Color _accentColor(BuildContext context) { + if (result.isCompleted) return Theme.of(context).colorScheme.outline; + if (result.isNearby) return const Color(0xFFBA7517); // amber-400 + return const Color(0xFF1D9E75); // teal-400 + } + + @override + Widget build(BuildContext context) { + final accent = _accentColor(context); + final textTheme = Theme.of(context).textTheme; + + return AnimatedContainer( + duration: const Duration(milliseconds: 400), + curve: Curves.easeInOut, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: _bgColor(context), + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + border: Border.all(color: accent.withOpacity(0.3)), + boxShadow: AppTheme.softShadow, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Cabecera: icono + etiqueta + punto vivo + Row( children: [ - Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: AppTheme.primary, - borderRadius: BorderRadius.circular(12), - ), - child: const Icon( - Icons.delete_outline_rounded, - color: Colors.white, - size: 24, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Camión recolector', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - color: AppTheme.primaryDark, - ), - ), - const SizedBox(height: 2), - AppStatusBadge.green(result.etiquetaEstado), - ], - ), - ), - _LiveDot(active: result.status == 'en_ruta'), - ], - ), - - const SizedBox(height: 20), - - // Mensaje ETA - Text( - result.mensaje, - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.w700, - color: AppTheme.primaryDark, - height: 1.3, + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: accent, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.delete_outline_rounded, + color: Colors.white, + size: 24, ), ), - - const SizedBox(height: 16), - - // Barra de progreso - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: result.progreso, - backgroundColor: - AppTheme.primaryMid.withValues(alpha: 0.35), - valueColor: - const AlwaysStoppedAnimation(AppTheme.primary), - minHeight: 8, - ), - ), - const SizedBox(height: 6), - const Row( - children: [ - Text('Inicio de ruta', - style: TextStyle( - fontSize: 10, color: AppTheme.primaryDark)), - Spacer(), - Text('Tu casa', - style: TextStyle( - fontSize: 10, color: AppTheme.primaryDark)), - ], - ), - ], - ), - ), - - const SizedBox(height: 16), - - // ── Domicilio registrado ─────────────────────────────────────── - AppInfoRow( - icon: Icons.home_outlined, - label: 'Col. ${result.colonia}', - value: result.direccion.isEmpty ? 'Mi domicilio' : result.direccion, - trailing: AppStatusBadge.green('Activo'), - ), - - const SizedBox(height: 16), - - // ── Aviso de privacidad ──────────────────────────────────────── - Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: AppTheme.blueLight, - borderRadius: BorderRadius.circular(AppTheme.radiusMd), - ), - child: const Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.shield_outlined, color: AppTheme.blue, size: 18), - SizedBox(width: 10), + const SizedBox(width: 12), Expanded( - child: Text( - 'Tu ubicación exacta y la del camión no se comparten. Solo ves el estado de tu ruta.', - style: TextStyle( - fontSize: 12, color: AppTheme.blue, height: 1.5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Camión recolector', + style: textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + color: accent, + ), + ), + const SizedBox(height: 2), + _StatusPill(result: result, accent: accent), + ], ), ), + _LiveDot(active: result.status == 'en_ruta'), + ], + ), + + const SizedBox(height: 16), + + // Ventana horaria o mensaje de estado + Text( + result.mensaje.isNotEmpty + ? result.mensaje + : _windowLabel(result.status), + style: textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w600, + color: accent, + height: 1.2, + ), + ), + + 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, + ), + ), + 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)), + ), ], ), + ], + ), + ); + } + + String _windowLabel(String s) { + switch (s) { + case 'completada': + return 'Servicio finalizado'; + case 'diferida': + return 'Servicio diferido'; + case 'reasignada': + return 'Ruta reasignada'; + default: + return 'En camino'; + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Pill de estado con punto pulsante +// ───────────────────────────────────────────────────────────────────────────── +class _StatusPill extends StatelessWidget { + final _EtaResult result; + final Color accent; + const _StatusPill({required this.result, required this.accent}); + + @override + Widget build(BuildContext context) { + final label = result.isNearby + ? 'Cerca de tu domicilio' + : result.isCompleted + ? 'Servicio completado' + : 'En camino a tu sector'; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!result.isCompleted) _PulsingDot(color: accent), + if (!result.isCompleted) const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: accent.withOpacity(0.15), + borderRadius: BorderRadius.circular(100), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: accent, + ), + ), ), - - const SizedBox(height: 16), - - // ── Horario estimado de la semana ────────────────────────────── - AppSectionTitle(title: 'Horario del camión'), - _HorarioCard(), ], ); } } -// ── Punto animado "en vivo" ─────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── +// Punto pulsante (animación de opacidad) +// ───────────────────────────────────────────────────────────────────────────── +class _PulsingDot extends StatefulWidget { + final Color color; + const _PulsingDot({required this.color}); + + @override + State<_PulsingDot> createState() => _PulsingDotState(); +} + +class _PulsingDotState extends State<_PulsingDot> + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + late final Animation _anim; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(reverse: true); + _anim = Tween(begin: 1.0, end: 0.3).animate(_ctrl); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _anim, + builder: (_, __) => Opacity( + opacity: _anim.value, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: widget.color, + shape: BoxShape.circle, + ), + ), + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Punto vivo "EN VIVO" (escala + opacidad) +// ───────────────────────────────────────────────────────────────────────────── class _LiveDot extends StatefulWidget { final bool active; const _LiveDot({required this.active}); @@ -292,34 +508,87 @@ class _LiveDotState extends State<_LiveDot> @override Widget build(BuildContext context) { - if (!widget.active) { - return const SizedBox.shrink(); - } + if (!widget.active) return const SizedBox.shrink(); return AnimatedBuilder( animation: _anim, - builder: (_, child) => Container( + builder: (_, __) => Container( width: 10, height: 10, decoration: BoxDecoration( shape: BoxShape.circle, - color: AppTheme.primary - .withValues(alpha: 0.5 + _anim.value * 0.5), + color: AppTheme.primary.withValues(alpha: 0.5 + _anim.value * 0.5), ), ), ); } } -// ── Horario ─────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── +// Badge de suscripción FCM +// ───────────────────────────────────────────────────────────────────────────── +class _FcmStatusBadge extends ConsumerWidget { + const _FcmStatusBadge(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final routeId = ref.watch(activeRouteIdProvider); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), + ), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Color(0xFF1D9E75), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text.rich( + TextSpan( + children: [ + const TextSpan( + text: 'Notificaciones activas ', + style: TextStyle(fontWeight: FontWeight.w500), + ), + TextSpan( + text: routeId != null + ? 'para topic_$routeId' + : '— suscribiendo...', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + style: const TextStyle(fontSize: 12), + ), + ), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Horario semanal +// ───────────────────────────────────────────────────────────────────────────── class _HorarioCard extends StatelessWidget { - final List<_HorarioDia> _dias = const [ - _HorarioDia(dia: 'Lunes', hora: '8:00 – 10:00 a.m.', activo: true), - _HorarioDia(dia: 'Martes', hora: '8:00 – 10:00 a.m.', activo: true), - _HorarioDia(dia: 'Miércoles',hora: 'Sin servicio', activo: false), - _HorarioDia(dia: 'Jueves', hora: '8:00 – 10:00 a.m.', activo: true), - _HorarioDia(dia: 'Viernes', hora: '8:00 – 10:00 a.m.', activo: true), - _HorarioDia(dia: 'Sábado', hora: '9:00 – 11:00 a.m.', activo: true), - _HorarioDia(dia: 'Domingo', hora: 'Sin servicio', activo: false), + static const _dias = [ + _HorarioDia(dia: 'Lunes', hora: '8:00 – 10:00 a.m.', activo: true), + _HorarioDia(dia: 'Martes', hora: '8:00 – 10:00 a.m.', activo: true), + _HorarioDia(dia: 'Miércoles', hora: 'Sin servicio', activo: false), + _HorarioDia(dia: 'Jueves', hora: '8:00 – 10:00 a.m.', activo: true), + _HorarioDia(dia: 'Viernes', hora: '8:00 – 10:00 a.m.', activo: true), + _HorarioDia(dia: 'Sábado', hora: '9:00 – 11:00 a.m.', activo: true), + _HorarioDia(dia: 'Domingo', hora: 'Sin servicio', activo: false), ]; @override @@ -369,11 +638,16 @@ class _HorarioDia { final String dia; final String hora; final bool activo; - const _HorarioDia( - {required this.dia, required this.hora, required this.activo}); + const _HorarioDia({ + required this.dia, + required this.hora, + required this.activo, + }); } -// ── Sin domicilio ───────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── +// Sin domicilio registrado +// ───────────────────────────────────────────────────────────────────────────── class _NoAddressState extends StatelessWidget { final VoidCallback onAdd; const _NoAddressState({required this.onAdd}); @@ -393,23 +667,30 @@ class _NoAddressState extends StatelessWidget { color: AppTheme.primaryLight, shape: BoxShape.circle, ), - child: const Icon(Icons.home_outlined, - color: AppTheme.primary, size: 40), + child: const Icon( + Icons.home_outlined, + color: AppTheme.primary, + size: 40, + ), ), const SizedBox(height: 20), const Text( 'Sin domicilio registrado', style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.w700, - color: AppTheme.textPrimary), + fontSize: 17, + fontWeight: FontWeight.w700, + color: AppTheme.textPrimary, + ), ), const SizedBox(height: 8), const Text( 'Registra tu domicilio para\nrecibir el ETA de tu ruta.', textAlign: TextAlign.center, style: TextStyle( - fontSize: 13, color: AppTheme.textSecondary, height: 1.5), + fontSize: 13, + color: AppTheme.textSecondary, + height: 1.5, + ), ), const SizedBox(height: 24), SizedBox( @@ -426,7 +707,9 @@ class _NoAddressState extends StatelessWidget { } } -// ── Cargando ────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── +// Cargando +// ───────────────────────────────────────────────────────────────────────────── class _EtaLoading extends StatelessWidget { const _EtaLoading(); @@ -434,19 +717,23 @@ class _EtaLoading extends StatelessWidget { Widget build(BuildContext context) { return const Center( child: Column( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(color: AppTheme.primary), SizedBox(height: 16), - Text('Consultando estado del camión…', - style: TextStyle(color: AppTheme.textSecondary, fontSize: 14)), + Text( + 'Consultando estado del servicio...', + style: TextStyle(color: AppTheme.textSecondary, fontSize: 14), + ), ], ), ); } } -// ── Error ───────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── +// Error +// ───────────────────────────────────────────────────────────────────────────── class _EtaError extends StatelessWidget { final String error; final VoidCallback onRetry; @@ -460,25 +747,36 @@ class _EtaError extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.wifi_off_outlined, - color: AppTheme.textSecondary, size: 48), + const Icon( + Icons.wifi_off_rounded, + color: AppTheme.textSecondary, + size: 48, + ), const SizedBox(height: 16), - const Text('No se pudo obtener el estado', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary)), + const Text( + 'No se pudo obtener el estado', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), const SizedBox(height: 8), - Text(error, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 12, color: AppTheme.textSecondary)), + Text( + error, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), const SizedBox(height: 20), SizedBox( width: 160, - child: ElevatedButton( + child: FilledButton.icon( onPressed: onRetry, - child: const Text('Reintentar'), + icon: const Icon(Icons.refresh_rounded), + label: const Text('Reintentar'), ), ), ], diff --git a/recolecta_app/lib/features/eta/eta_service.dart b/recolecta_app/lib/features/eta/eta_service.dart new file mode 100644 index 0000000..2ddb758 --- /dev/null +++ b/recolecta_app/lib/features/eta/eta_service.dart @@ -0,0 +1,25 @@ +// lib/features/eta/eta_service.dart +// Llama a GET /eta?address_id=X via dio. +// La respuesta NUNCA contiene coordenadas (validado en backend + RLS). + +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:recolecta_app/core/network/api_client.dart'; +import 'package:recolecta_app/features/eta/eta_model.dart'; + +class EtaService { + final Dio _dio; + EtaService(this._dio); + + Future fetchEta(String addressId) async { + final response = await _dio.get>( + '/eta', + queryParameters: {'address_id': addressId}, + ); + return EtaResponse.fromJson(response.data!); + } +} + +final etaServiceProvider = Provider( + (ref) => EtaService(ref.read(apiClientProvider)), +); diff --git a/recolecta_app/lib/features/notifications/notification_service.dart b/recolecta_app/lib/features/notifications/notification_service.dart new file mode 100644 index 0000000..587c9ef --- /dev/null +++ b/recolecta_app/lib/features/notifications/notification_service.dart @@ -0,0 +1,130 @@ +// lib/features/notifications/notification_service.dart +// Gestiona FCM: suscripción a topic, handlers foreground/background. +// +// Regla de privacidad: los payloads de push NUNCA contienen lat/lng. +// El backend solo manda title/body desde notificaciones.json. + +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +// Canal Android de alta prioridad para alertas de proximidad +const _kChannelId = 'recolecta_alerts'; +const _kChannelName = 'Alertas de recolección'; +const _kChannelDesc = 'Notificaciones de llegada del camión recolector'; + +/// Notifier simple: la EtaScreen lo escucha para refrescar sin polling. +class _FcmMessageNotifier extends ChangeNotifier { + RemoteMessage? lastMessage; + void notify(RemoteMessage msg) { + lastMessage = msg; + notifyListeners(); + } +} + +// Handler de background/terminated (top-level, fuera de clase) +@pragma('vm:entry-point') +Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { + // Solo loguear; la EtaScreen se refrescará cuando la app vuelva a foreground. + debugPrint('[FCM background] ${message.notification?.title}'); +} + +class NotificationService { + NotificationService._(); + + static final _messaging = FirebaseMessaging.instance; + static final _localNotifications = FlutterLocalNotificationsPlugin(); + static final onFcmMessage = _FcmMessageNotifier(); + + /// Inicializar una sola vez en main.dart + static Future initialize() async { + // Registrar handler de background + FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); + + // Solicitar permisos (iOS + Android 13+) + final settings = await _messaging.requestPermission( + alert: true, + badge: true, + sound: true, + ); + debugPrint('[FCM] Permission: ${settings.authorizationStatus}'); + + // Canal Android + const androidChannel = AndroidNotificationChannel( + _kChannelId, + _kChannelName, + description: _kChannelDesc, + importance: Importance.high, + ); + await _localNotifications + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>() + ?.createNotificationChannel(androidChannel); + + // Inicializar flutter_local_notifications + const initSettings = InitializationSettings( + android: AndroidInitializationSettings('@mipmap/ic_launcher'), + iOS: DarwinInitializationSettings(), + ); + await _localNotifications.initialize(initSettings); + + // Foreground: mostrar notificación local + notificar EtaScreen + FirebaseMessaging.onMessage.listen((message) { + _showLocalNotification(message); + onFcmMessage.notify(message); + }); + + // Tap en notificación cuando la app estaba en background + FirebaseMessaging.onMessageOpenedApp.listen((message) { + onFcmMessage.notify(message); + }); + + // Verificar si la app abrió desde una notificación (terminated) + final initial = await _messaging.getInitialMessage(); + if (initial != null) { + onFcmMessage.notify(initial); + } + } + + /// Suscribir al topic de la ruta del ciudadano. + /// Llamar justo después de que verified = true en el domicilio. + static Future subscribeToRoute(String routeId) async { + final topic = 'topic_$routeId'; + await _messaging.subscribeToTopic(topic); + debugPrint('[FCM] Suscrito a $topic'); + } + + /// Desuscribir (al cambiar de domicilio / colonia) + static Future unsubscribeFromRoute(String routeId) async { + final topic = 'topic_$routeId'; + await _messaging.unsubscribeFromTopic(topic); + debugPrint('[FCM] Desuscrito de $topic'); + } + + static Future _showLocalNotification(RemoteMessage message) async { + final notification = message.notification; + if (notification == null) return; + + // El payload del backend es solo title+body; NUNCA contiene coordenadas. + await _localNotifications.show( + notification.hashCode, + notification.title, + notification.body, + NotificationDetails( + android: AndroidNotificationDetails( + _kChannelId, + _kChannelName, + channelDescription: _kChannelDesc, + importance: Importance.high, + priority: Priority.high, + // Sin ningún campo de mapa o ubicación + ), + iOS: const DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + ), + ), + ); + } +} \ No newline at end of file diff --git a/recolecta_app/lib/features/notifications/notifications_screen.dart b/recolecta_app/lib/features/notifications/notifications_screen.dart index a1cb270..ef7a4b3 100644 --- a/recolecta_app/lib/features/notifications/notifications_screen.dart +++ b/recolecta_app/lib/features/notifications/notifications_screen.dart @@ -1,20 +1,378 @@ +// lib/features/notifications/notifications_screen.dart +// Historial de notificaciones FCM recibidas. +// Los items se almacenan en memoria (no en BD) — solo mensajes del topic propio. + +import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; -import '../../core/theme/app_theme.dart'; - -class NotificationsScreen extends StatelessWidget { +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'notification_service.dart'; +import '../eta/eta_screen.dart'; // activeRouteIdProvider + +// ────────────────────────────────────────── +// Modelo local de item de notificación +// ────────────────────────────────────────── +enum FcmEventType { routeStart, truckProximity, routeCompleted, reassignment, unknown } + +FcmEventType _eventTypeFromMessage(RemoteMessage msg) { + final type = msg.data['event'] as String?; + switch (type) { + case 'ROUTE_START': + return FcmEventType.routeStart; + case 'TRUCK_PROXIMITY': + return FcmEventType.truckProximity; + case 'ROUTE_COMPLETED': + return FcmEventType.routeCompleted; + case 'reasignacion': + case 'retraso': + return FcmEventType.reassignment; + default: + return FcmEventType.unknown; + } +} + +class NotificationItem { + final String title; + final String body; + final FcmEventType type; + final DateTime receivedAt; + + const NotificationItem({ + required this.title, + required this.body, + required this.type, + required this.receivedAt, + }); +} + +// ────────────────────────────────────────── +// Provider: lista de notificaciones en memoria +// ────────────────────────────────────────── +final notificationsListProvider = + NotifierProvider>( + NotificationsNotifier.new, +); + +class NotificationsNotifier extends Notifier> { + @override + List build() { + // Escuchar mensajes FCM en foreground + NotificationService.onFcmMessage.addListener(_onMessage); + ref.onDispose( + () => NotificationService.onFcmMessage.removeListener(_onMessage), + ); + return []; + } + + void _onMessage() { + final msg = NotificationService.onFcmMessage.lastMessage; + if (msg == null) return; + final item = NotificationItem( + title: msg.notification?.title ?? 'Recolección', + body: msg.notification?.body ?? '', + type: _eventTypeFromMessage(msg), + receivedAt: DateTime.now(), + ); + state = [item, ...state]; + } + + void clearAll() => state = []; +} + +// ────────────────────────────────────────── +// Pantalla de notificaciones +// ────────────────────────────────────────── +class NotificationsScreen extends ConsumerWidget { const NotificationsScreen({super.key}); - + + @override + Widget build(BuildContext context, WidgetRef ref) { + final items = ref.watch(notificationsListProvider); + final routeId = ref.watch(activeRouteIdProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Notificaciones'), + actions: [ + if (items.isNotEmpty) + TextButton( + onPressed: () => + ref.read(notificationsListProvider.notifier).clearAll(), + child: const Text('Limpiar'), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + children: [ + // Badge de suscripción FCM + _FcmTopicBadge(routeId: routeId), + const SizedBox(height: 12), + + // Aviso de privacidad + _PrivacyNote(), + const SizedBox(height: 16), + + if (items.isEmpty) + const _EmptyState() + else ...[ + const _SectionLabel(label: 'Recientes'), + ...items.map((item) => _NotificationCard(item: item)), + ], + ], + ), + ); + } +} + +// ────────────────────────────────────────── +// Widgets auxiliares +// ────────────────────────────────────────── +class _FcmTopicBadge extends StatelessWidget { + final String? routeId; + const _FcmTopicBadge({required this.routeId}); + @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppTheme.background, - appBar: AppBar(title: const Text('Avisos y Alertas')), - body: const Center( - child: Text( - 'Bandeja de entrada de FCM', - style: TextStyle(color: AppTheme.textSecondary), + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Color(0xFF1D9E75), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text.rich( + TextSpan(children: [ + const TextSpan( + text: 'Suscrito a ', + style: TextStyle(fontSize: 12), + ), + TextSpan( + text: routeId != null + ? 'topic_$routeId' + : 'topic pendiente', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + const TextSpan( + text: ' · Solo recibes eventos de tu ruta', + style: TextStyle(fontSize: 12), + ), + ]), + ), + ), + ], + ), + ); + } +} + +class _PrivacyNote extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFAEEDA), // amber-50 + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFFAC775)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.info_outline_rounded, + size: 18, color: Color(0xFFBA7517)), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Los mensajes no revelan la ubicación del camión. Solo se muestra el tiempo estimado de llegada.', + style: const TextStyle(fontSize: 12, color: Color(0xFF633806)), + maxLines: 3, + ), + ), + ], + ), + ); + } +} + +class _SectionLabel extends StatelessWidget { + final String label; + const _SectionLabel({required this.label}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + label.toUpperCase(), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + letterSpacing: 0.8, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ); } } + +class _NotificationCard extends StatelessWidget { + final NotificationItem item; + const _NotificationCard({required this.item}); + + IconData get _icon { + switch (item.type) { + case FcmEventType.routeStart: + return Icons.arrow_forward_rounded; + case FcmEventType.truckProximity: + return Icons.local_shipping_rounded; + case FcmEventType.routeCompleted: + return Icons.check_circle_outline_rounded; + case FcmEventType.reassignment: + return Icons.swap_horiz_rounded; + default: + return Icons.notifications_outlined; + } + } + + Color _accentColor() { + switch (item.type) { + case FcmEventType.routeStart: + return const Color(0xFF1D9E75); + case FcmEventType.truckProximity: + return const Color(0xFFBA7517); + case FcmEventType.routeCompleted: + return Colors.grey; + case FcmEventType.reassignment: + return const Color(0xFF378ADD); + default: + return Colors.grey; + } + } + + String _relativeTime() { + final diff = DateTime.now().difference(item.receivedAt); + if (diff.inMinutes < 1) return 'Ahora mismo'; + if (diff.inMinutes < 60) return 'Hace ${diff.inMinutes} min'; + if (diff.inHours < 24) return 'Hace ${diff.inHours} h'; + return 'Ayer'; + } + + @override + Widget build(BuildContext context) { + final accent = _accentColor(); + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(10), + border: Border( + left: BorderSide(color: accent, width: 3), + top: BorderSide(color: Theme.of(context).colorScheme.outlineVariant, width: 0.5), + right: BorderSide(color: Theme.of(context).colorScheme.outlineVariant, width: 0.5), + bottom: BorderSide(color: Theme.of(context).colorScheme.outlineVariant, width: 0.5), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: accent.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(_icon, size: 16, color: accent), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + item.body, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + height: 1.4, + ), + ), + const SizedBox(height: 4), + Text( + _relativeTime(), + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 48), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.notifications_none_rounded, + size: 48, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + 'Sin notificaciones aún', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + 'Recibirás un aviso cuando el camión esté cerca.', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/recolecta_app/lib/features/splash/splash_screen.dart b/recolecta_app/lib/features/splash/splash_screen.dart new file mode 100644 index 0000000..cfb6a44 --- /dev/null +++ b/recolecta_app/lib/features/splash/splash_screen.dart @@ -0,0 +1,312 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class SplashScreen extends StatefulWidget { + const SplashScreen({super.key}); + + @override + State createState() => _SplashScreenState(); +} + +class _SplashScreenState extends State + with TickerProviderStateMixin { + late final AnimationController _logoCtrl; + late final AnimationController _textCtrl; + late final AnimationController _bubblesCtrl; + + late final Animation _logoScale; + late final Animation _logoOpacity; + late final Animation _textSlide; + late final Animation _textOpacity; + late final Animation _subtitleOpacity; + + @override + void initState() { + super.initState(); + + _logoCtrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + ); + _textCtrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 700), + ); + _bubblesCtrl = AnimationController( + vsync: this, + duration: const Duration(seconds: 6), + )..repeat(); + + _logoScale = Tween(begin: 0.2, end: 1.0).animate( + CurvedAnimation(parent: _logoCtrl, curve: Curves.elasticOut), + ); + _logoOpacity = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _logoCtrl, + curve: const Interval(0.0, 0.4, curve: Curves.easeIn), + ), + ); + _textSlide = Tween( + begin: const Offset(0, 0.4), + end: Offset.zero, + ).animate(CurvedAnimation(parent: _textCtrl, curve: Curves.easeOut)); + _textOpacity = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _textCtrl, + curve: const Interval(0.0, 0.6, curve: Curves.easeIn), + ), + ); + _subtitleOpacity = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _textCtrl, + curve: const Interval(0.4, 1.0, curve: Curves.easeIn), + ), + ); + + _runSequence(); + } + + Future _runSequence() async { + await Future.delayed(const Duration(milliseconds: 400)); + if (!mounted) return; + _logoCtrl.forward(); + await Future.delayed(const Duration(milliseconds: 600)); + if (!mounted) return; + _textCtrl.forward(); + await Future.delayed(const Duration(milliseconds: 2200)); + if (mounted) context.go('/login'); + } + + @override + void dispose() { + _logoCtrl.dispose(); + _textCtrl.dispose(); + _bubblesCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + stops: [0.0, 0.55, 1.0], + colors: [ + Color(0xFF0A4A38), + Color(0xFF0F6E56), + Color(0xFF1D9E75), + ], + ), + ), + child: Stack( + children: [ + // Burbujas decorativas animadas + AnimatedBuilder( + animation: _bubblesCtrl, + builder: (_, _) => CustomPaint( + painter: _BubblesPainter(_bubblesCtrl.value), + size: Size.infinite, + ), + ), + + SafeArea( + child: Column( + children: [ + const Spacer(flex: 3), + + // Logo central + ScaleTransition( + scale: _logoScale, + child: FadeTransition( + opacity: _logoOpacity, + child: Container( + width: 118, + height: 118, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(34), + border: Border.all( + color: Colors.white.withValues(alpha: 0.35), + width: 2, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 24, + offset: const Offset(0, 8), + ), + ], + ), + child: const Icon( + Icons.recycling_rounded, + size: 64, + color: Colors.white, + ), + ), + ), + ), + + const SizedBox(height: 36), + + // Nombre de la app + SlideTransition( + position: _textSlide, + child: FadeTransition( + opacity: _textOpacity, + child: const Text( + 'RecolectApp', + style: TextStyle( + fontSize: 38, + fontWeight: FontWeight.w800, + color: Colors.white, + letterSpacing: -1.0, + height: 1.1, + ), + ), + ), + ), + + const SizedBox(height: 10), + + // Subtítulo + FadeTransition( + opacity: _subtitleOpacity, + child: Text( + 'Sistema de Recolección Inteligente', + style: TextStyle( + fontSize: 14, + color: Colors.white.withValues(alpha: 0.8), + letterSpacing: 0.4, + fontWeight: FontWeight.w400, + ), + ), + ), + + const Spacer(flex: 3), + + // Indicador de carga + FadeTransition( + opacity: _subtitleOpacity, + child: const Padding( + padding: EdgeInsets.only(bottom: 52), + child: _DotsLoader(), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +// ── Loader de tres puntos ──────────────────────────────────────────────────── + +class _DotsLoader extends StatefulWidget { + const _DotsLoader(); + + @override + State<_DotsLoader> createState() => _DotsLoaderState(); +} + +class _DotsLoaderState extends State<_DotsLoader> + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1400), + )..repeat(); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _ctrl, + builder: (_, _) { + return Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(3, (i) { + final phase = (_ctrl.value - i * 0.2).clamp(0.0, 1.0); + final wave = (sin(phase * pi)).clamp(0.0, 1.0); + return AnimatedContainer( + duration: Duration.zero, + margin: const EdgeInsets.symmetric(horizontal: 4), + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.35 + 0.65 * wave), + ), + ); + }), + ); + }, + ); + } +} + +// ── Burbujas decorativas de fondo ──────────────────────────────────────────── + +class _BubblesPainter extends CustomPainter { + final double t; + + _BubblesPainter(this.t); + + static const _bubbles = [ + _Bubble(0.08, 0.15, 60, 0.0), + _Bubble(0.85, 0.08, 90, 0.2), + _Bubble(0.72, 0.78, 50, 0.5), + _Bubble(0.15, 0.85, 70, 0.7), + _Bubble(0.50, 0.05, 40, 0.35), + _Bubble(0.92, 0.55, 35, 0.9), + ]; + + @override + void paint(Canvas canvas, Size size) { + for (final b in _bubbles) { + final phase = (t + b.phase) % 1.0; + final floatY = sin(phase * 2 * pi) * 12; + final paint = Paint() + ..color = Colors.white.withValues(alpha: 0.04 + 0.03 * sin(phase * pi)) + ..style = PaintingStyle.fill; + canvas.drawCircle( + Offset(b.xFrac * size.width, b.yFrac * size.height + floatY), + b.radius, + paint, + ); + final strokePaint = Paint() + ..color = Colors.white.withValues(alpha: 0.07) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + canvas.drawCircle( + Offset(b.xFrac * size.width, b.yFrac * size.height + floatY), + b.radius, + strokePaint, + ); + } + } + + @override + bool shouldRepaint(_BubblesPainter old) => old.t != t; +} + +class _Bubble { + final double xFrac, yFrac, radius, phase; + const _Bubble(this.xFrac, this.yFrac, this.radius, this.phase); +} diff --git a/recolecta_app/lib/shared/widgets/prevention_banner.dart b/recolecta_app/lib/shared/widgets/prevention_banner.dart new file mode 100644 index 0000000..4edb065 --- /dev/null +++ b/recolecta_app/lib/shared/widgets/prevention_banner.dart @@ -0,0 +1,51 @@ +// lib/shared/widgets/prevention_banner.dart +// Banner de mensajería preventiva — obligatorio en la vista ETA. +// Regla de privacidad #5: textos que desalientan sacar basura fuera de horario +// o perseguir la unidad. + +import 'package:flutter/material.dart'; + +class PreventionBanner extends StatelessWidget { + final String? customMessage; + + const PreventionBanner({super.key, this.customMessage}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFAEEDA), // amber-50 + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFFAC775)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(top: 1), + child: Icon( + Icons.warning_amber_rounded, + size: 18, + color: Color(0xFFBA7517), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + customMessage ?? + 'No saques tu basura antes de recibir el aviso de proximidad ' + 'ni dejes bolsas en la calle por más de 30 min. ' + 'No persigas ni detengas la unidad recolectora.', + style: const TextStyle( + fontSize: 12, + color: Color(0xFF633806), + height: 1.5, + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/recolecta_app/lib/shared/widgets/progress_steps.dart b/recolecta_app/lib/shared/widgets/progress_steps.dart new file mode 100644 index 0000000..0a95e31 --- /dev/null +++ b/recolecta_app/lib/shared/widgets/progress_steps.dart @@ -0,0 +1,174 @@ +// lib/shared/widgets/progress_steps.dart +// Barra de 4 pasos del servicio. Sin mapa ni coordenadas. +// Los pasos mapean a los eventos de positionId del backend: +// 0 = pendiente, 1 = ROUTE_START (pos 2), 2 = TRUCK_PROXIMITY (pos 4), 3 = ROUTE_COMPLETED (pos 8) + +import 'package:flutter/material.dart'; + +class ProgressSteps extends StatelessWidget { + /// 0 = pendiente, 1 = en camino, 2 = cerca, 3 = completado + final int stepIndex; + + const ProgressSteps({super.key, required this.stepIndex}); + + static const _steps = [ + _StepData('Servicio pendiente', Icons.access_time_rounded), + _StepData('Salió al sector', Icons.arrow_forward_rounded), + _StepData('Cerca (~15 min)', Icons.local_shipping_rounded), + _StepData('Finalizado', Icons.check_circle_outline_rounded), + ]; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 10, 14, 8), + child: Row( + children: [ + const Icon(Icons.route_rounded, + size: 16, color: Color(0xFF1D9E75)), + const SizedBox(width: 6), + Text( + 'Progreso del servicio', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ], + ), + ), + const Divider(height: 1, thickness: 0.5), + ...List.generate(_steps.length, (i) { + final status = _stepStatus(i); + return _StepRow( + data: _steps[i], + status: status, + isLast: i == _steps.length - 1, + ); + }), + ], + ), + ); + } + + _Status _stepStatus(int i) { + if (i < stepIndex) return _Status.done; + if (i == stepIndex) return _Status.active; + return _Status.pending; + } +} + +enum _Status { done, active, pending } + +class _StepData { + final String label; + final IconData icon; + const _StepData(this.label, this.icon); +} + +class _StepRow extends StatelessWidget { + final _StepData data; + final _Status status; + final bool isLast; + + const _StepRow({ + required this.data, + required this.status, + required this.isLast, + }); + + @override + Widget build(BuildContext context) { + Color iconBg; + Color iconColor; + IconData displayIcon; + + switch (status) { + case _Status.done: + iconBg = const Color(0xFFE1F5EE); + iconColor = const Color(0xFF1D9E75); + displayIcon = Icons.check_rounded; + break; + case _Status.active: + iconBg = const Color(0xFFFAEEDA); + iconColor = const Color(0xFFBA7517); + displayIcon = data.icon; + break; + case _Status.pending: + iconBg = Theme.of(context).colorScheme.surfaceContainerLow; + iconColor = Theme.of(context).colorScheme.onSurfaceVariant; + displayIcon = Icons.radio_button_unchecked_rounded; + break; + } + + return Container( + decoration: BoxDecoration( + border: isLast + ? null + : Border( + bottom: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, + width: 0.5, + ), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row( + children: [ + Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: iconBg, + shape: BoxShape.circle, + ), + child: Icon(displayIcon, size: 15, color: iconColor), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + data.label, + style: TextStyle( + fontSize: 13, + color: status == _Status.pending + ? Theme.of(context).colorScheme.onSurfaceVariant + : Theme.of(context).colorScheme.onSurface, + ), + ), + ), + if (status == _Status.active) + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFFAEEDA), + borderRadius: BorderRadius.circular(100), + ), + child: const Text( + 'Ahora', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: Color(0xFF633806), + ), + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/recolecta_app/macos/Flutter/GeneratedPluginRegistrant.swift b/recolecta_app/macos/Flutter/GeneratedPluginRegistrant.swift index 7397616..677cdb0 100644 --- a/recolecta_app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/recolecta_app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,12 +7,16 @@ import Foundation import firebase_core import firebase_messaging +import flutter_local_notifications import flutter_secure_storage_macos import sqflite_darwin +import video_player_avfoundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + VideoPlayerPlugin.register(with: registry.registrar(forPlugin: "VideoPlayerPlugin")) } diff --git a/recolecta_app/pubspec.lock b/recolecta_app/pubspec.lock index 3bf8624..f3aa26f 100644 --- a/recolecta_app/pubspec.lock +++ b/recolecta_app/pubspec.lock @@ -137,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" cupertino_icons: dependency: "direct main" description: @@ -145,6 +153,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" + dbus: + dependency: transitive + description: + name: dbus + sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91" + url: "https://pub.dev" + source: hosted + version: "0.7.13" dio: dependency: "direct main" description: @@ -270,6 +286,30 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35" + url: "https://pub.dev" + source: hosted + version: "17.2.4" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af + url: "https://pub.dev" + source: hosted + version: "4.0.1" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66" + url: "https://pub.dev" + source: hosted + version: "7.2.0" flutter_map: dependency: "direct main" description: @@ -376,6 +416,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" http: dependency: transitive description: @@ -632,6 +680,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" platform: dependency: transitive description: @@ -877,6 +933,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.17" + timezone: + dependency: transitive + description: + name: timezone + sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d" + url: "https://pub.dev" + source: hosted + version: "0.9.4" typed_data: dependency: transitive description: @@ -909,6 +973,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + video_player: + dependency: "direct main" + description: + name: video_player + sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: "877a6c7ba772456077d7bfd71314629b3fe2b73733ce503fc77c3314d43a0ca0" + url: "https://pub.dev" + source: hosted + version: "2.9.5" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: "9338f3ec22774f88146b22f13273a446719b1da010fd200c4d1d97802156ac58" + url: "https://pub.dev" + source: hosted + version: "2.9.7" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: "16eaed5268c571c31840dc58ef8da5f0cd4db2a98490c3b8f1cf70122546c6e0" + url: "https://pub.dev" + source: hosted + version: "6.7.0" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" + url: "https://pub.dev" + source: hosted + version: "2.4.0" vm_service: dependency: transitive description: @@ -981,6 +1085,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.dev" + source: hosted + version: "7.0.1" yaml: dependency: transitive description: diff --git a/recolecta_app/pubspec.yaml b/recolecta_app/pubspec.yaml index c295d60..746dd32 100644 --- a/recolecta_app/pubspec.yaml +++ b/recolecta_app/pubspec.yaml @@ -40,10 +40,12 @@ dependencies: go_router: ^14.6.2 firebase_core: ^3.8.0 firebase_messaging: ^15.1.5 + flutter_local_notifications: ^17.1.2 flutter_secure_storage: ^9.2.4 cached_network_image: ^3.4.1 flutter_map: ^6.1.0 latlong2: ^0.9.0 + video_player: ^2.9.2 dev_dependencies: flutter_test: @@ -65,6 +67,7 @@ flutter: # - assets/images/ - assets/.env - assets/data/separation_guide.json + - assets/animations/ # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. diff --git a/views_v3/admin_screen.dart b/views_v3/admin_screen.dart index 5fad745..64546e7 100644 --- a/views_v3/admin_screen.dart +++ b/views_v3/admin_screen.dart @@ -67,6 +67,38 @@ class _AdminScreenState extends State { ), ]; + final List _rutas = [ + 'Ruta Norte', + 'Ruta Sur', + ]; + + final List _reportes = [ + ReportModel( + id: 'report-01', + truckId: 'truck-01', + titulo: 'Reporte de ruta bloqueada', + descripcion: 'El camión no puede continuar porque la ruta está obstruida.', + fecha: DateTime.now().subtract(Duration(hours: 2)), + estado: 'Pendiente', + ), + ReportModel( + id: 'report-02', + truckId: 'truck-01', + titulo: 'Revisión mecánica urgente', + descripcion: 'Se detectó una fuga de aceite en el motor.', + fecha: DateTime.now().subtract(Duration(days: 1, hours: 3)), + estado: 'En revisión', + ), + ReportModel( + id: 'report-03', + truckId: 'truck-02', + titulo: 'Retraso en servicio', + descripcion: 'El camión llegó tarde por un problema en la carretera.', + fecha: DateTime.now().subtract(Duration(hours: 5)), + estado: 'Resuelto', + ), + ]; + void _selectSection(int index) { setState(() => _selectedSection = index); } @@ -82,6 +114,9 @@ class _AdminScreenState extends State { case 2: _showTruckForm(); break; + case 3: + _showRouteForm(); + break; } } @@ -244,7 +279,29 @@ class _AdminScreenState extends State { children: [ _buildTextField('Placa', placa), const SizedBox(height: 12), - _buildTextField('Ruta', ruta), + if (_rutas.isNotEmpty) + DropdownButtonFormField( + initialValue: _rutas.contains(ruta.text) ? ruta.text : _rutas.first, + decoration: InputDecoration( + labelText: 'Ruta', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + ), + ), + items: _rutas + .map((rutaName) => DropdownMenuItem( + value: rutaName, + child: Text(rutaName), + )) + .toList(), + onChanged: (value) { + if (value != null) { + ruta.text = value; + } + }, + ) + else + _buildTextField('Ruta', ruta), const SizedBox(height: 12), DropdownButtonFormField( initialValue: conductorId, @@ -318,6 +375,293 @@ class _AdminScreenState extends State { ); } + void _showRouteForm({String? ruta}) { + final nombreRuta = TextEditingController(text: ruta ?? ''); + final formKey = GlobalKey(); + + showDialog( + context: context, + builder: (ctx) { + return AlertDialog( + title: Text(ruta == null ? 'Agregar ruta' : 'Editar ruta'), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + ), + content: Form( + key: formKey, + child: _buildTextField('Nombre de la ruta', nombreRuta), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancelar'), + ), + ElevatedButton( + onPressed: () { + if (!formKey.currentState!.validate()) return; + final nuevoNombre = nombreRuta.text.trim(); + setState(() { + if (ruta == null) { + _rutas.add(nuevoNombre); + } else { + final index = _rutas.indexOf(ruta); + if (index != -1) { + _rutas[index] = nuevoNombre; + for (var i = 0; i < _camiones.length; i++) { + if (_camiones[i].ruta == ruta) { + _camiones[i] = TruckModel( + id: _camiones[i].id, + placa: _camiones[i].placa, + ruta: nuevoNombre, + conductorId: _camiones[i].conductorId, + activo: _camiones[i].activo, + ); + } + } + for (var i = 0; i < _conductores.length; i++) { + if (_conductores[i].rutaAsignada == ruta) { + _conductores[i] = DriverModel( + id: _conductores[i].id, + nombre: _conductores[i].nombre, + telefono: _conductores[i].telefono, + placa: _conductores[i].placa, + rutaAsignada: nuevoNombre, + ); + } + } + } + } + }); + Navigator.pop(ctx); + }, + child: const Text('Guardar'), + ), + ], + ); + }, + ); + } + + void _showReportForm({ReportModel? reporte}) { + final titulo = TextEditingController(text: reporte?.titulo ?? ''); + final descripcion = TextEditingController(text: reporte?.descripcion ?? ''); + String selectedTruckId = reporte?.truckId ?? _camiones.first.id; + final formKey = GlobalKey(); + + showDialog( + context: context, + builder: (ctx) { + return StatefulBuilder( + builder: (ctx, setStateDialog) { + return AlertDialog( + title: Text(reporte == null ? 'Agregar reporte' : 'Editar reporte'), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + ), + content: Form( + key: formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownButtonFormField( + initialValue: selectedTruckId, + decoration: InputDecoration( + labelText: 'Unidad', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + ), + ), + items: _camiones + .map((truck) => DropdownMenuItem( + value: truck.id, + child: Text('${truck.placa} · ${truck.ruta}'), + )) + .toList(), + onChanged: (value) { + if (value != null) { + setStateDialog(() => selectedTruckId = value); + } + }, + ), + const SizedBox(height: 12), + _buildTextField('Título', titulo), + const SizedBox(height: 12), + _buildTextField('Descripción', descripcion), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancelar'), + ), + ElevatedButton( + onPressed: () { + if (!formKey.currentState!.validate()) return; + final nuevo = ReportModel( + id: reporte?.id ?? 'report-${DateTime.now().millisecondsSinceEpoch}', + truckId: selectedTruckId, + titulo: titulo.text.trim(), + descripcion: descripcion.text.trim(), + fecha: DateTime.now(), + ); + setState(() { + if (reporte == null) { + _reportes.add(nuevo); + } else { + final index = _reportes.indexWhere((r) => r.id == reporte.id); + if (index != -1) { + _reportes[index] = nuevo; + } + } + }); + Navigator.pop(ctx); + }, + child: const Text('Guardar'), + ), + ], + ); + }, + ); + }, + ); + } + + Widget _buildReportSection() { + final header = Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Reportes de unidades', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + ), + TextButton.icon( + icon: const Icon(Icons.add), + label: const Text('Agregar reporte'), + onPressed: _showReportForm, + ), + ], + ), + ); + + if (_camiones.isEmpty) { + return Column( + children: [ + header, + const SizedBox(height: 10), + const Expanded(child: Center(child: Text('No hay unidades registradas.'))), + ], + ); + } + + final Map> reportesPorUnidad = { + for (var camion in _camiones) camion.id: [], + }; + for (var reporte in _reportes) { + reportesPorUnidad[reporte.truckId]?.add(reporte); + } + + final unidadesOrdenadas = List.from(_camiones) + ..sort((a, b) => a.placa.compareTo(b.placa)); + + return Column( + children: [ + header, + const SizedBox(height: 10), + Expanded( + child: ListView( + padding: const EdgeInsets.only(bottom: 16), + children: unidadesOrdenadas.map((camion) { + final reportes = reportesPorUnidad[camion.id] ?? []; + reportes.sort((a, b) => b.fecha.compareTo(a.fecha)); + + return w.AppCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(camion.placa, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + )), + const SizedBox(height: 6), + Text(camion.ruta, + style: const TextStyle(color: AppTheme.textSecondary)), + ], + ), + ), + w.StatusBadge.gray('${reportes.length} reporte(s)'), + ], + ), + const SizedBox(height: 12), + if (reportes.isEmpty) + const Text('No hay reportes para esta unidad.', + style: TextStyle(color: AppTheme.textSecondary)) + else + Column( + children: reportes + .map((reporte) => Padding( + padding: const EdgeInsets.only(top: 12), + child: Container( + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + border: Border.all(color: AppTheme.border), + ), + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text(reporte.titulo, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + )), + ), + Text(reporte.fechaFormateada, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary)), + ], + ), + const SizedBox(height: 6), + Text(reporte.descripcion, + style: const TextStyle(color: AppTheme.textSecondary)), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: w.StatusBadge.amber(reporte.estado), + ), + ], + ), + ), + )) + .toList(), + ), + ], + ), + ); + }).toList(), + ), + ), + ], + ); + } + Widget _buildTextField(String label, TextEditingController controller, {TextInputType keyboardType = TextInputType.text}) { return TextFormField( @@ -352,7 +696,33 @@ class _AdminScreenState extends State { ), TextButton( onPressed: () { - setState(() => lista.remove(item)); + setState(() { + lista.remove(item); + if (item is String && identical(lista, _rutas)) { + for (var i = 0; i < _camiones.length; i++) { + if (_camiones[i].ruta == item) { + _camiones[i] = TruckModel( + id: _camiones[i].id, + placa: _camiones[i].placa, + ruta: '', + conductorId: _camiones[i].conductorId, + activo: _camiones[i].activo, + ); + } + } + for (var i = 0; i < _conductores.length; i++) { + if (_conductores[i].rutaAsignada == item) { + _conductores[i] = DriverModel( + id: _conductores[i].id, + nombre: _conductores[i].nombre, + telefono: _conductores[i].telefono, + placa: _conductores[i].placa, + rutaAsignada: '', + ); + } + } + } + }); Navigator.pop(ctx); }, style: TextButton.styleFrom(foregroundColor: AppTheme.danger), @@ -402,7 +772,13 @@ class _AdminScreenState extends State { return Scaffold( backgroundColor: AppTheme.background, appBar: AppBar( - title: Text(_currentTab == 0 ? 'Mi perfil' : 'Panel administrador'), + title: Text( + _currentTab == 0 + ? 'Mi perfil' + : _currentTab == 1 + ? 'Panel administrador' + : 'Reportes por unidad', + ), actions: _currentTab == 1 ? [ IconButton( @@ -411,13 +787,22 @@ class _AdminScreenState extends State { tooltip: 'Agregar', ), ] - : null, + : _currentTab == 2 + ? [ + IconButton( + icon: const Icon(Icons.add), + onPressed: _showReportForm, + tooltip: 'Agregar reporte', + ), + ] + : null, ), body: IndexedStack( index: _currentTab, children: [ _buildAdminProfile(), _buildAdminBody(), + _buildReportSection(), ], ), bottomNavigationBar: BottomNavigationBar( @@ -432,6 +817,10 @@ class _AdminScreenState extends State { icon: Icon(Icons.admin_panel_settings_outlined), label: 'Admin', ), + BottomNavigationBarItem( + icon: Icon(Icons.report_problem_outlined), + label: 'Reportes', + ), ], ), ); @@ -448,6 +837,7 @@ class _AdminScreenState extends State { _buildSectionButton('Usuarios', 0), _buildSectionButton('Conductores', 1), _buildSectionButton('Camiones', 2), + _buildSectionButton('Rutas', 3), ], ), ), @@ -576,11 +966,74 @@ class _AdminScreenState extends State { return _buildUsuarioSection(); case 1: return _buildDriverSection(); - default: + case 2: return _buildTruckSection(); + case 3: + return _buildRouteSection(); + default: + return _buildReportSection(); } } + Widget _buildRouteSection() { + if (_rutas.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('No hay rutas registradas.'), + const SizedBox(height: 12), + ElevatedButton( + onPressed: _showRouteForm, + child: const Text('Agregar ruta'), + ), + ], + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.only(bottom: 16), + itemCount: _rutas.length, + itemBuilder: (context, index) { + final ruta = _rutas[index]; + final asignados = _camiones.where((camion) => camion.ruta == ruta).length; + return w.AppCard( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(ruta, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + )), + const SizedBox(height: 6), + Text('$asignados camión(es) asignado(s)', + style: const TextStyle(color: AppTheme.textSecondary)), + ], + ), + ), + IconButton( + icon: const Icon(Icons.edit_outlined), + onPressed: () => _showRouteForm(ruta: ruta), + ), + IconButton( + icon: const Icon(Icons.delete_outline, color: AppTheme.danger), + onPressed: () { + _confirmDelete(ruta, _rutas, 'ruta'); + }, + ), + ], + ), + ); + }, + ); + } + Widget _buildUsuarioSection() { if (_usuarios.isEmpty) { return const Center(child: Text('No hay usuarios registrados.'));