From fc28333e3ffbe0edea5dc618d3812494627ddd86 Mon Sep 17 00:00:00 2001 From: shinra32 Date: Fri, 22 May 2026 19:45:05 -0600 Subject: [PATCH] bLOQUE p1 BACKEND Y SEGURIDAD, AUTENTICACION CON SUPABASE. jwt. RBAC CRUD --- backend/app/api/__init__.py | 0 backend/app/api/addresses.py | 94 ++++++++ backend/app/api/auth.py | 90 ++++++++ backend/app/api/colonias.py | 11 + backend/app/api/eta.py | 5 - backend/app/core/__init__.py | 0 backend/app/core/config.py | 21 ++ backend/app/core/deps.py | 46 ++++ backend/app/core/supabase_client.py | 8 + backend/app/db/rls_policies.sql | 146 +++++++++++++ backend/app/main.py | 18 -- backend/app/schemas/__init__.py | 0 backend/app/schemas/addresses.py | 21 ++ backend/app/schemas/auth.py | 22 ++ backend/app/schemas/colonias.py | 8 + backend/app/services/notifications.py | 5 +- backend/main.py | 62 +++--- backend/requirements.txt | 13 +- recolecta_app/lib/app/app.dart | 116 +++++----- recolecta_app/lib/app/bootstrap.dart | 36 ++++ recolecta_app/lib/core/auth/.gitkeep | 0 recolecta_app/lib/core/constants/.gitkeep | 0 .../lib/core/constants/auth_constants.dart | 3 + recolecta_app/lib/core/models/.gitkeep | 0 recolecta_app/lib/core/models/address.dart | 22 ++ .../lib/core/models/auth_session.dart | 11 + recolecta_app/lib/core/models/auth_state.dart | 28 +++ recolecta_app/lib/core/models/colonia.dart | 37 ++++ recolecta_app/lib/core/network/.gitkeep | 0 .../lib/core/network/api_client.dart | 34 +++ recolecta_app/lib/core/services/.gitkeep | 0 .../lib/core/services/auth_controller.dart | 61 ++++++ .../lib/core/services/auth_service.dart | 149 +++++++++++++ .../lib/core/services/colonias_service.dart | 39 ++++ recolecta_app/lib/core/storage/.gitkeep | 0 .../lib/core/storage/secure_storage.dart | 6 + recolecta_app/lib/core/theme/.gitkeep | 0 recolecta_app/lib/core/utils/.gitkeep | 0 recolecta_app/lib/features/addresses/.gitkeep | 0 .../features/addresses/colonias_provider.dart | 8 + .../features/addresses/colonias_selector.dart | 120 +++++++++++ .../features/addresses/new_address_page.dart | 110 ++++++++++ recolecta_app/lib/features/admin/.gitkeep | 0 recolecta_app/lib/features/auth/.gitkeep | 0 .../lib/features/auth/login_page.dart | 164 ++++++++++++++ .../lib/features/auth/register_page.dart | 200 ++++++++++++++++++ recolecta_app/lib/features/driver/.gitkeep | 0 recolecta_app/lib/features/eta/.gitkeep | 0 recolecta_app/lib/features/feedback/.gitkeep | 0 .../lib/features/notifications/.gitkeep | 0 .../lib/features/separation_guide/.gitkeep | 0 recolecta_app/lib/shared/.gitkeep | 0 52 files changed, 1605 insertions(+), 109 deletions(-) create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/addresses.py create mode 100644 backend/app/api/auth.py create mode 100644 backend/app/api/colonias.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/deps.py create mode 100644 backend/app/core/supabase_client.py create mode 100644 backend/app/db/rls_policies.sql delete mode 100644 backend/app/main.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/addresses.py create mode 100644 backend/app/schemas/auth.py create mode 100644 backend/app/schemas/colonias.py create mode 100644 recolecta_app/lib/app/bootstrap.dart create mode 100644 recolecta_app/lib/core/auth/.gitkeep create mode 100644 recolecta_app/lib/core/constants/.gitkeep create mode 100644 recolecta_app/lib/core/constants/auth_constants.dart create mode 100644 recolecta_app/lib/core/models/.gitkeep create mode 100644 recolecta_app/lib/core/models/address.dart create mode 100644 recolecta_app/lib/core/models/auth_session.dart create mode 100644 recolecta_app/lib/core/models/auth_state.dart create mode 100644 recolecta_app/lib/core/models/colonia.dart create mode 100644 recolecta_app/lib/core/network/.gitkeep create mode 100644 recolecta_app/lib/core/network/api_client.dart create mode 100644 recolecta_app/lib/core/services/.gitkeep create mode 100644 recolecta_app/lib/core/services/auth_controller.dart create mode 100644 recolecta_app/lib/core/services/auth_service.dart create mode 100644 recolecta_app/lib/core/services/colonias_service.dart create mode 100644 recolecta_app/lib/core/storage/.gitkeep create mode 100644 recolecta_app/lib/core/storage/secure_storage.dart create mode 100644 recolecta_app/lib/core/theme/.gitkeep create mode 100644 recolecta_app/lib/core/utils/.gitkeep create mode 100644 recolecta_app/lib/features/addresses/.gitkeep create mode 100644 recolecta_app/lib/features/addresses/colonias_provider.dart create mode 100644 recolecta_app/lib/features/addresses/colonias_selector.dart create mode 100644 recolecta_app/lib/features/addresses/new_address_page.dart create mode 100644 recolecta_app/lib/features/admin/.gitkeep create mode 100644 recolecta_app/lib/features/auth/.gitkeep create mode 100644 recolecta_app/lib/features/auth/login_page.dart create mode 100644 recolecta_app/lib/features/auth/register_page.dart create mode 100644 recolecta_app/lib/features/driver/.gitkeep create mode 100644 recolecta_app/lib/features/eta/.gitkeep create mode 100644 recolecta_app/lib/features/feedback/.gitkeep create mode 100644 recolecta_app/lib/features/notifications/.gitkeep create mode 100644 recolecta_app/lib/features/separation_guide/.gitkeep create mode 100644 recolecta_app/lib/shared/.gitkeep diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/addresses.py b/backend/app/api/addresses.py new file mode 100644 index 0000000..5a12a98 --- /dev/null +++ b/backend/app/api/addresses.py @@ -0,0 +1,94 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from app.schemas.addresses import AddressCreate, AddressResponse +from app.core.deps import get_current_user, require_role +from app.core.supabase_client import supabase_admin +from app.services.simulation import get_colonias + +router = APIRouter(prefix="/addresses", tags=["addresses"]) + + +def _resolve_route_id(colonia: str) -> str: + """Deriva routeId desde colonias-rutas.json; lanza 404 si la colonia no existe.""" + mapping = get_colonias() + match = next( + (c for c in mapping if c.get("colonia", "").lower() == colonia.lower()), None + ) + if not match: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Colonia '{colonia}' no encontrada. Usa GET /colonias para ver las opciones.", + ) + return match["routeId"] + + +@router.post("", response_model=AddressResponse, status_code=status.HTTP_201_CREATED) +def create_address( + body: AddressCreate, + current_user: dict = Depends(require_role("citizen", "admin")), +): + """Alta de domicilio. El routeId se deriva automáticamente de la colonia elegida.""" + route_id = _resolve_route_id(body.colonia) + + result = ( + supabase_admin.table("addresses") + .insert( + { + "user_id": current_user["user_id"], + "label": body.label, + "calle": body.calle, + "colonia": body.colonia, + "route_id": route_id, + "verified": False, + } + ) + .execute() + ) + + if not result.data: + raise HTTPException(status_code=500, detail="No se pudo guardar el domicilio") + + return result.data[0] + + +@router.get("", response_model=list[AddressResponse]) +def list_addresses(current_user: dict = Depends(get_current_user)): + """ + Lista de domicilios. + - Ciudadano: solo sus propios (filtro por user_id en código + RLS en BD). + - Admin: todos los domicilios. + """ + if current_user["role"] == "admin": + result = supabase_admin.table("addresses").select("*").execute() + else: + result = ( + supabase_admin.table("addresses") + .select("*") + .eq("user_id", current_user["user_id"]) + .execute() + ) + + return result.data or [] + + +@router.get("/{address_id}", response_model=AddressResponse) +def get_address( + address_id: str, + current_user: dict = Depends(get_current_user), +): + """Detalle de un domicilio. El ciudadano solo puede ver los suyos.""" + result = ( + supabase_admin.table("addresses") + .select("*") + .eq("id", address_id) + .maybe_single() + .execute() + ) + + if not result.data: + raise HTTPException(status_code=404, detail="Domicilio no encontrado") + + address = result.data + if current_user["role"] != "admin" and address["user_id"] != current_user["user_id"]: + raise HTTPException(status_code=403, detail="No tienes acceso a este domicilio") + + return address diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..82e2b73 --- /dev/null +++ b/backend/app/api/auth.py @@ -0,0 +1,90 @@ +from fastapi import APIRouter, HTTPException, status +from app.schemas.auth import RegisterRequest, LoginRequest, TokenResponse +from app.core.supabase_client import supabase, supabase_admin + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +def _fetch_role(user_id: str) -> str: + result = ( + supabase_admin.table("users") + .select("role") + .eq("id", user_id) + .maybe_single() + .execute() + ) + return result.data["role"] if result.data else "citizen" + + +@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED) +def register(body: RegisterRequest): + """ + Registro por email o teléfono. + - Email: flujo estándar Supabase email+password. + - Teléfono: requiere que Supabase tenga configurado un proveedor SMS (Twilio). + """ + if not body.email and not body.phone: + raise HTTPException(status_code=400, detail="Se requiere email o teléfono") + + try: + if body.email: + resp = supabase.auth.sign_up({"email": body.email, "password": body.password}) + else: + resp = supabase.auth.sign_up({"phone": body.phone, "password": body.password}) + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + auth_user = resp.user + 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 + supabase_admin.table("users").upsert( + { + "id": str(auth_user.id), + "email": body.email, + "phone": body.phone, + "role": body.role, + } + ).execute() + + # Si no hubo sesión (email confirmation pendiente) devolvemos token vacío con aviso + if not resp.session: + raise HTTPException( + status_code=202, + detail="Cuenta creada. Confirma tu email antes de iniciar sesión.", + ) + + return TokenResponse( + access_token=resp.session.access_token, + user_id=str(auth_user.id), + role=body.role, + ) + + +@router.post("/login", response_model=TokenResponse) +def login(body: LoginRequest): + """Login por email o teléfono; devuelve JWT de Supabase.""" + if not body.email and not body.phone: + raise HTTPException(status_code=400, detail="Se requiere email o teléfono") + + try: + if body.email: + resp = supabase.auth.sign_in_with_password( + {"email": body.email, "password": body.password} + ) + else: + resp = supabase.auth.sign_in_with_password( + {"phone": body.phone, "password": body.password} + ) + except Exception: + raise HTTPException(status_code=401, detail="Credenciales inválidas") + + auth_user = resp.user + role = _fetch_role(str(auth_user.id)) + + return TokenResponse( + access_token=resp.session.access_token, + user_id=str(auth_user.id), + role=role, + ) diff --git a/backend/app/api/colonias.py b/backend/app/api/colonias.py new file mode 100644 index 0000000..cc30d6a --- /dev/null +++ b/backend/app/api/colonias.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter + +from app.schemas.colonias import ColoniaResponse +from app.services.simulation import get_colonias + +router = APIRouter(tags=["colonias"]) + + +@router.get("/colonias", response_model=list[ColoniaResponse]) +def list_colonias(): + return get_colonias() \ No newline at end of file diff --git a/backend/app/api/eta.py b/backend/app/api/eta.py index c616e50..f993ffc 100644 --- a/backend/app/api/eta.py +++ b/backend/app/api/eta.py @@ -5,11 +5,6 @@ from app.services import simulation router = APIRouter() -@router.get("/colonias") -def list_colonias(): - return simulation.get_colonias() - - @router.get("/eta") def get_eta(colonia: Optional[str] = None, routeId: Optional[str] = None): # Resolver routeId a partir de colonia si es necesario diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..9807d23 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,21 @@ +from functools import lru_cache +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + SUPABASE_URL: str + SUPABASE_ANON_KEY: str + SUPABASE_SERVICE_ROLE_KEY: str + + FIREBASE_CREDENTIALS_PATH: str = "app/data/secrets/recoleccion-app-firebase-adminsdk-fbsvc-3da79d2a4c.json" + SIMULATION_TICK_SECONDS: int = 10 + + +@lru_cache +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py new file mode 100644 index 0000000..ced4590 --- /dev/null +++ b/backend/app/core/deps.py @@ -0,0 +1,46 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from app.core.supabase_client import supabase, supabase_admin + +security = HTTPBearer() + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), +) -> dict: + """Valida el JWT de Supabase y devuelve {user_id, email, role}.""" + token = credentials.credentials + try: + resp = supabase.auth.get_user(token) + auth_user = resp.user + if auth_user is None: + raise ValueError("usuario nulo") + except Exception: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token inválido o expirado", + headers={"WWW-Authenticate": "Bearer"}, + ) + + result = ( + supabase_admin.table("users") + .select("role") + .eq("id", str(auth_user.id)) + .maybe_single() + .execute() + ) + role = result.data["role"] if result.data else "citizen" + + return {"user_id": str(auth_user.id), "email": auth_user.email, "role": role} + + +def require_role(*roles: str): + """Factory: devuelve una dependencia que exige que el usuario tenga uno de los roles dados.""" + async def checker(current_user: dict = Depends(get_current_user)) -> dict: + if current_user["role"] not in roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Rol requerido: {' o '.join(roles)}", + ) + return current_user + return checker diff --git a/backend/app/core/supabase_client.py b/backend/app/core/supabase_client.py new file mode 100644 index 0000000..f53be8c --- /dev/null +++ b/backend/app/core/supabase_client.py @@ -0,0 +1,8 @@ +from supabase import create_client, Client +from app.core.config import settings + +# Cliente con anon key — para operaciones de auth y llamadas ciudadanas +supabase: Client = create_client(settings.SUPABASE_URL, settings.SUPABASE_ANON_KEY) + +# Cliente con service_role — bypasea RLS; solo para operaciones de backend admin +supabase_admin: Client = create_client(settings.SUPABASE_URL, settings.SUPABASE_SERVICE_ROLE_KEY) diff --git a/backend/app/db/rls_policies.sql b/backend/app/db/rls_policies.sql new file mode 100644 index 0000000..e6f4662 --- /dev/null +++ b/backend/app/db/rls_policies.sql @@ -0,0 +1,146 @@ +-- ============================================================ +-- RLS Policies — Sistema de Recolección Inteligente +-- Ejecutar en: Supabase > SQL Editor +-- Regla innegociable: el ciudadano NUNCA ve coordenadas ni datos ajenos. +-- ============================================================ + + +-- ------------------------------------------------------------ +-- 1. Tabla public.users (espejo de auth.users con rol) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS public.users ( + id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + email TEXT, + phone TEXT, + role TEXT DEFAULT 'citizen' + CHECK (role IN ('citizen', 'driver', 'admin')), + created_at TIMESTAMPTZ DEFAULT NOW() +); + +ALTER TABLE public.users ENABLE ROW LEVEL SECURITY; + +-- Cada usuario ve y edita solo su propia fila +DROP POLICY IF EXISTS "users_select_own" ON public.users; +CREATE POLICY "users_select_own" ON public.users + FOR SELECT USING (auth.uid() = id); + +DROP POLICY IF EXISTS "users_update_own" ON public.users; +CREATE POLICY "users_update_own" ON public.users + FOR UPDATE USING (auth.uid() = id); + +-- El backend (service_role) inserta al registrar; no necesita policy +-- porque service_role bypasea RLS por diseño. + + +-- ------------------------------------------------------------ +-- 2. Tabla public.addresses +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS public.addresses ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + label TEXT NOT NULL, + calle TEXT NOT NULL, + colonia TEXT NOT NULL, + route_id TEXT NOT NULL, + verified BOOLEAN DEFAULT FALSE, + verified_method TEXT, + verified_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +ALTER TABLE public.addresses ENABLE ROW LEVEL SECURITY; + +-- Ciudadano: solo lee sus domicilios; admin lee todos +DROP POLICY IF EXISTS "addresses_select" ON public.addresses; +CREATE POLICY "addresses_select" ON public.addresses + FOR SELECT USING ( + auth.uid() = user_id + OR (SELECT role FROM public.users WHERE id = auth.uid()) = 'admin' + ); + +-- Ciudadano solo inserta domicilios propios +DROP POLICY IF EXISTS "addresses_insert" ON public.addresses; +CREATE POLICY "addresses_insert" ON public.addresses + FOR INSERT WITH CHECK (auth.uid() = user_id); + +-- Ciudadano solo modifica los suyos (para verified=true tras OCR) +DROP POLICY IF EXISTS "addresses_update" ON public.addresses; +CREATE POLICY "addresses_update" ON public.addresses + FOR UPDATE USING ( + auth.uid() = user_id + OR (SELECT role FROM public.users WHERE id = auth.uid()) = 'admin' + ); + + +-- ------------------------------------------------------------ +-- 3. Tabla public.route_positions ← ÚNICA QUE TIENE LAT/LNG +-- Solo admin puede leerla. Regla innegociable #1 y #4. +-- ------------------------------------------------------------ +ALTER TABLE public.route_positions ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "route_positions_admin_only" ON public.route_positions; +CREATE POLICY "route_positions_admin_only" ON public.route_positions + FOR SELECT USING ( + (SELECT role FROM public.users WHERE id = auth.uid()) = 'admin' + ); +-- Sin policy para INSERT/UPDATE/DELETE → el backend usa service_role para el seed. + + +-- ------------------------------------------------------------ +-- 4. Tabla public.notifications +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS public.notifications ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + user_id UUID REFERENCES public.users(id) ON DELETE CASCADE, + route_id TEXT, + type TEXT, + payload JSONB, + sent_at TIMESTAMPTZ DEFAULT NOW() +); + +ALTER TABLE public.notifications ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "notifications_select" ON public.notifications; +CREATE POLICY "notifications_select" ON public.notifications + FOR SELECT USING ( + auth.uid() = user_id + OR (SELECT role FROM public.users WHERE id = auth.uid()) = 'admin' + ); + + +-- ------------------------------------------------------------ +-- 5. Tabla public.feedback +-- Las quejas van a target_unit_id (unidad), NUNCA al chofer. +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS public.feedback ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + user_id UUID REFERENCES public.users(id) ON DELETE SET NULL, + address_id UUID REFERENCES public.addresses(id) ON DELETE SET NULL, + type TEXT, + target_unit_id INT, -- unidad (no chofer): privacidad del chofer + message TEXT, + rating SMALLINT CHECK (rating BETWEEN 1 AND 5), + created_at TIMESTAMPTZ DEFAULT NOW() +); + +ALTER TABLE public.feedback ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "feedback_select" ON public.feedback; +CREATE POLICY "feedback_select" ON public.feedback + FOR SELECT USING ( + auth.uid() = user_id + OR (SELECT role FROM public.users WHERE id = auth.uid()) = 'admin' + ); + +DROP POLICY IF EXISTS "feedback_insert" ON public.feedback; +CREATE POLICY "feedback_insert" ON public.feedback + FOR INSERT WITH CHECK (auth.uid() = user_id); + + +-- ------------------------------------------------------------ +-- 6. Índices útiles para rendimiento +-- ------------------------------------------------------------ +CREATE INDEX IF NOT EXISTS idx_addresses_user_id ON public.addresses(user_id); +CREATE INDEX IF NOT EXISTS idx_addresses_route_id ON public.addresses(route_id); +CREATE INDEX IF NOT EXISTS idx_notifications_user ON public.notifications(user_id); +CREATE INDEX IF NOT EXISTS idx_feedback_user ON public.feedback(user_id); diff --git a/backend/app/main.py b/backend/app/main.py deleted file mode 100644 index 2d4e547..0000000 --- a/backend/app/main.py +++ /dev/null @@ -1,18 +0,0 @@ -from fastapi import FastAPI -from app.api.eta import router as eta_router -from app.services import simulation -from app.services import notifications -import os - -app = FastAPI(title="Recoleccion API") -app.include_router(eta_router) - - -@app.on_event("startup") -async def startup_event(): - # Carga los datos en memoria al iniciar la app - simulation.load_data() - simulation.start_simulation_state() - # Inicializar Firebase Admin si hay credenciales - cred_path = os.environ.get("FIREBASE_CREDENTIALS_PATH", "backend/secrets/firebase-adminsdk.json") - notifications.init_firebase(cred_path) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/addresses.py b/backend/app/schemas/addresses.py new file mode 100644 index 0000000..1d6fae5 --- /dev/null +++ b/backend/app/schemas/addresses.py @@ -0,0 +1,21 @@ +from pydantic import BaseModel +from typing import Optional + + +class AddressCreate(BaseModel): + label: str + calle: str + colonia: str # el backend deriva route_id a partir de colonias-rutas.json + + +class AddressResponse(BaseModel): + id: str + user_id: str + label: str + calle: str + colonia: str + route_id: str + verified: bool + verified_method: Optional[str] = None + verified_at: Optional[str] = None + created_at: Optional[str] = None diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..cbdbcb4 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel, EmailStr +from typing import Optional, Literal + + +class RegisterRequest(BaseModel): + email: Optional[str] = None + phone: Optional[str] = None + password: str + role: Literal["citizen", "driver", "admin"] = "citizen" + + +class LoginRequest(BaseModel): + email: Optional[str] = None + phone: Optional[str] = None + password: str + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + user_id: str + role: str diff --git a/backend/app/schemas/colonias.py b/backend/app/schemas/colonias.py new file mode 100644 index 0000000..5204cbc --- /dev/null +++ b/backend/app/schemas/colonias.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel + + +class ColoniaResponse(BaseModel): + colonia: str + routeId: str + turno: str | None = None + horario_estimado: str | None = None \ No newline at end of file diff --git a/backend/app/services/notifications.py b/backend/app/services/notifications.py index 80301ea..193b073 100644 --- a/backend/app/services/notifications.py +++ b/backend/app/services/notifications.py @@ -23,8 +23,11 @@ def init_firebase(cred_path: str): print(f"ADVERTENCIA: Credenciales no encontradas en '{cred_path}'.") print("Las notificaciones se ejecutarán en modo SIMULADO (solo consola).") -def send_to_topic(topic: str, title: str, body: str): +def send_to_topic(topic: str, payload: dict): """Envía una notificación push a todos los dispositivos suscritos a un topic (ej. RUTA-01).""" + title = payload.get("title", "") + body = payload.get("body", "") + if not _firebase_initialized: print(f"[MOCK PUSH] -> Topic: {topic} | Título: '{title}' | Mensaje: '{body}'") return diff --git a/backend/main.py b/backend/main.py index aa7dd79..6425803 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,58 +1,72 @@ from contextlib import asynccontextmanager from fastapi import FastAPI -import os +from starlette.middleware.cors import CORSMiddleware from apscheduler.schedulers.asyncio import AsyncIOScheduler +from app.core.config import settings from app.api.eta import router as eta_router +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.services import simulation, notifications scheduler = AsyncIOScheduler() + @asynccontextmanager async def lifespan(app: FastAPI): - """ - Maneja el ciclo de vida de la aplicación. - """ print("Iniciando aplicación: Backend Sistema de Recolección...") - - # 1. Cargar datos de simulación + simulation.load_data() simulation.start_simulation_state() - - # 2. Inicializar Firebase (o Mock si no hay credenciales) - # Ruta relativa correcta cuando se ejecuta desde la carpeta /backend - cred_path = os.environ.get("FIREBASE_CREDENTIALS_PATH", "secrets/firebase-adminsdk.json") - notifications.init_firebase(cred_path) - - # 3. Arrancar el scheduler de simulación - tick_seconds = int(os.environ.get("SIMULATION_TICK_SECONDS", 15)) - scheduler.add_job(simulation.tick, 'interval', seconds=tick_seconds, id='simulation_tick') + + notifications.init_firebase(settings.FIREBASE_CREDENTIALS_PATH) + + scheduler.add_job( + simulation.tick, + "interval", + seconds=settings.SIMULATION_TICK_SECONDS, + id="simulation_tick", + ) scheduler.start() - print(f"Simulador de rutas iniciado. Avanzando cada {tick_seconds} segundos.") + print(f"Simulador iniciado. Tick cada {settings.SIMULATION_TICK_SECONDS}s.") yield print("Apagando aplicación y deteniendo simulador...") scheduler.shutdown() + app = FastAPI( - title="API - Recolección Inteligente y Privada", + title="API — Recolección Inteligente y Privada", description="Backend para el sistema de recolección de residuos con privacidad por diseño.", version="1.0.0", - lifespan=lifespan + lifespan=lifespan, ) -# Incluir routers de la API -app.include_router(eta_router) +# CORS — necesario para el simulador web y el cliente Flutter +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # En producción limitar a dominios reales + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth_router) +app.include_router(addresses_router) +app.include_router(eta_router) +app.include_router(colonias_router) + -# Endpoints de prueba base @app.get("/") def read_root(): return { - "status": "ok", - "message": "Backend operativo. Regla Innegociable 1: NUNCA se devuelven coordenadas del camión al ciudadano." + "status": "ok", + "message": "Backend operativo. Regla innegociable #1: NUNCA se devuelven coordenadas al ciudadano.", } + @app.get("/health") def health_check(): - return {"status": "healthy"} \ No newline at end of file + return {"status": "healthy"} diff --git a/backend/requirements.txt b/backend/requirements.txt index 578b2ff..ccac716 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,14 +1,11 @@ -fastapi>=0.95.0 -uvicorn[standard]>=0.22.0 -firebase-admin>=6.0.0 -apscheduler>=3.10.1 fastapi==0.111.0 uvicorn[standard]==0.29.0 +pydantic-settings==2.2.1 +python-dotenv==1.0.1 +apscheduler==3.10.4 +supabase==2.4.5 +firebase-admin==6.5.0 sqlalchemy==2.0.30 psycopg2-binary==2.9.9 -apscheduler==3.10.4 python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 -pydantic-settings==2.2.1 -supabase==2.4.5 -firebase-admin==6.5.0 \ No newline at end of file diff --git a/recolecta_app/lib/app/app.dart b/recolecta_app/lib/app/app.dart index 3a38672..e015ee9 100644 --- a/recolecta_app/lib/app/app.dart +++ b/recolecta_app/lib/app/app.dart @@ -1,69 +1,61 @@ import 'package:cached_network_image/cached_network_image.dart'; -import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:go_router/go_router.dart'; -import 'package:firebase_core/firebase_core.dart'; -import 'package:firebase_messaging/firebase_messaging.dart'; -import '../firebase_options.dart'; -final bootstrapProvider = FutureProvider((ref) async { - await dotenv.load(fileName: 'assets/.env'); - - // Inicializar Firebase (si hay DefaultFirebaseOptions, úsalas; sino, intenta initializeApp() y espera que haya google-services/Info.plist) - final FirebaseOptions? options = DefaultFirebaseOptions.currentPlatform; - if (options != null) { - await Firebase.initializeApp(options: options); - } else { - await Firebase.initializeApp(); - } - - // Registrar handler para mensajes en background - FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); -}); - -// Handler top-level requerido por FCM -Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { - // Asegurar Firebase inicializado en background isolate - try { - await Firebase.initializeApp(); - } catch (_) { - // ignore if already initialized - } - // Aquí puedes procesar y guardar la notificación si hace falta - debugPrint( - 'FCM background message received: ${message.messageId} | data: ${message.data}', - ); -} - -final apiClientProvider = Provider((ref) { - final baseUrl = dotenv.env['API_BASE_URL'] ?? 'http://10.0.2.2:8000'; - - return Dio( - BaseOptions( - baseUrl: baseUrl, - connectTimeout: const Duration(seconds: 15), - receiveTimeout: const Duration(seconds: 15), - headers: const {'Content-Type': 'application/json'}, - ), - ); -}); - -final secureStorageProvider = Provider((ref) { - return const FlutterSecureStorage(); -}); +import '../core/network/api_client.dart'; +import '../core/services/auth_controller.dart'; +import '../core/storage/secure_storage.dart'; +import 'bootstrap.dart' as bootstrap; +import '../features/auth/login_page.dart'; +import '../features/auth/register_page.dart'; +import '../features/addresses/new_address_page.dart'; final routerProvider = Provider((ref) { + final authSnapshot = ref.watch(authControllerProvider); + final isAuthenticated = authSnapshot.asData?.value.isAuthenticated ?? false; + return GoRouter( initialLocation: '/home', + redirect: (context, state) { + final location = state.matchedLocation; + final isAuthRoute = location == '/login' || location == '/register'; + + if (authSnapshot.isLoading) { + return location == '/login' ? null : '/login'; + } + + if (!isAuthenticated) { + return isAuthRoute ? null : '/login'; + } + + if (isAuthenticated && isAuthRoute) { + return '/home'; + } + + return null; + }, routes: [ + GoRoute( + path: '/login', + name: 'login', + builder: (context, state) => const LoginPage(), + ), + GoRoute( + path: '/register', + name: 'register', + builder: (context, state) => const RegisterPage(), + ), GoRoute( path: '/home', name: 'home', builder: (context, state) => const HomePage(), ), + GoRoute( + path: '/addresses/new', + name: 'addresses-new', + builder: (context, state) => const NewAddressPage(), + ), GoRoute( path: '/status', name: 'status', @@ -78,9 +70,9 @@ class RecolectaApp extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final bootstrap = ref.watch(bootstrapProvider); + final bootstrapState = ref.watch(bootstrap.bootstrapProvider); - return bootstrap.when( + return bootstrapState.when( loading: () => const MaterialApp( debugShowCheckedModeBanner: false, home: BootstrapLoadingPage(), @@ -154,11 +146,24 @@ class HomePage extends ConsumerWidget { final dio = ref.read(apiClientProvider); final storage = ref.read(secureStorageProvider); final baseUrl = dio.options.baseUrl; + final authState = ref.watch(authControllerProvider); return Scaffold( appBar: AppBar( title: const Text('Recolecta'), actions: [ + IconButton( + onPressed: authState.isLoading + ? null + : () async { + await ref.read(authControllerProvider.notifier).logout(); + if (context.mounted) { + context.go('/login'); + } + }, + icon: const Icon(Icons.logout), + tooltip: 'Salir', + ), IconButton( onPressed: () => context.goNamed('status'), icon: const Icon(Icons.route), @@ -179,6 +184,11 @@ class HomePage extends ConsumerWidget { 'La app ya carga .env, Riverpod y GoRouter para la base del MVP.', style: Theme.of(context).textTheme.bodyLarge, ), + const SizedBox(height: 16), + TextButton( + onPressed: () => context.go('/addresses/new'), + child: const Text('Agregar domicilio'), + ), const SizedBox(height: 24), _InfoCard(title: 'API base URL', value: baseUrl, icon: Icons.cloud), const SizedBox(height: 16), diff --git a/recolecta_app/lib/app/bootstrap.dart b/recolecta_app/lib/app/bootstrap.dart new file mode 100644 index 0000000..54341ba --- /dev/null +++ b/recolecta_app/lib/app/bootstrap.dart @@ -0,0 +1,36 @@ +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../firebase_options.dart'; + +final bootstrapProvider = FutureProvider((ref) async { + await dotenv.load(fileName: 'assets/.env'); + await _initializeFirebase(); + FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); +}); + +Future _initializeFirebase() async { + try { + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ); + } on UnsupportedError { + await Firebase.initializeApp(); + } +} + +@pragma('vm:entry-point') +Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { + try { + await _initializeFirebase(); + } catch (_) { + // Ignore reinitialization errors in the background isolate. + } + + debugPrint( + 'FCM background message received: ${message.messageId} | data: ${message.data}', + ); +} diff --git a/recolecta_app/lib/core/auth/.gitkeep b/recolecta_app/lib/core/auth/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/recolecta_app/lib/core/constants/.gitkeep b/recolecta_app/lib/core/constants/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/recolecta_app/lib/core/constants/auth_constants.dart b/recolecta_app/lib/core/constants/auth_constants.dart new file mode 100644 index 0000000..0c4a9fc --- /dev/null +++ b/recolecta_app/lib/core/constants/auth_constants.dart @@ -0,0 +1,3 @@ +const String authTokenStorageKey = 'auth_jwt'; +const String authUserRoleStorageKey = 'auth_user_role'; +const String authRouteIdStorageKey = 'auth_route_id'; diff --git a/recolecta_app/lib/core/models/.gitkeep b/recolecta_app/lib/core/models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/recolecta_app/lib/core/models/address.dart b/recolecta_app/lib/core/models/address.dart new file mode 100644 index 0000000..e32086e --- /dev/null +++ b/recolecta_app/lib/core/models/address.dart @@ -0,0 +1,22 @@ +import 'colonia.dart'; + +class AddressModel { + const AddressModel({ + required this.label, + required this.street, + required this.colonia, + }); + + final String label; + final String street; + final Colonia colonia; + + Map toJson() { + return { + 'label': label, + 'calle': street, + 'colonia': colonia.nombre, + 'route_id': colonia.routeId, + }; + } +} diff --git a/recolecta_app/lib/core/models/auth_session.dart b/recolecta_app/lib/core/models/auth_session.dart new file mode 100644 index 0000000..5862eb1 --- /dev/null +++ b/recolecta_app/lib/core/models/auth_session.dart @@ -0,0 +1,11 @@ +class AuthSession { + const AuthSession({required this.token, this.userRole, this.routeId}); + + final String token; + final String? userRole; + final String? routeId; + + bool get isCitizen => userRole == 'citizen'; + bool get isDriver => userRole == 'driver'; + bool get isAdmin => userRole == 'admin'; +} diff --git a/recolecta_app/lib/core/models/auth_state.dart b/recolecta_app/lib/core/models/auth_state.dart new file mode 100644 index 0000000..5878a4b --- /dev/null +++ b/recolecta_app/lib/core/models/auth_state.dart @@ -0,0 +1,28 @@ +class AuthState { + const AuthState({ + required this.isAuthenticated, + this.token, + this.userRole, + this.routeId, + }); + + const AuthState.unauthenticated() + : isAuthenticated = false, + token = null, + userRole = null, + routeId = null; + + const AuthState.authenticated({ + required String token, + String? userRole, + String? routeId, + }) : isAuthenticated = true, + token = token, + userRole = userRole, + routeId = routeId; + + final bool isAuthenticated; + final String? token; + final String? userRole; + final String? routeId; +} diff --git a/recolecta_app/lib/core/models/colonia.dart b/recolecta_app/lib/core/models/colonia.dart new file mode 100644 index 0000000..e098ab3 --- /dev/null +++ b/recolecta_app/lib/core/models/colonia.dart @@ -0,0 +1,37 @@ +class Colonia { + const Colonia({ + required this.id, + required this.nombre, + this.routeId, + this.horarioEstimado, + this.turno, + }); + + final String id; + final String nombre; + final String? routeId; + final String? horarioEstimado; + final String? turno; + + factory Colonia.fromJson(Map json) { + final rawId = + json['id'] ?? + json['routeId'] ?? + json['route_id'] ?? + json['nombre'] ?? + json['name']; + final rawNombre = json['nombre'] ?? json['name'] ?? rawId; + + return Colonia( + id: rawId?.toString() ?? rawNombre?.toString() ?? '', + nombre: rawNombre?.toString() ?? '', + routeId: (json['routeId'] ?? json['route_id'])?.toString(), + horarioEstimado: + (json['horario_estimado'] ?? + json['horarioEstimado'] ?? + json['schedule']) + ?.toString(), + turno: (json['turno'] ?? json['shift'])?.toString(), + ); + } +} diff --git a/recolecta_app/lib/core/network/.gitkeep b/recolecta_app/lib/core/network/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/recolecta_app/lib/core/network/api_client.dart b/recolecta_app/lib/core/network/api_client.dart new file mode 100644 index 0000000..10880cf --- /dev/null +++ b/recolecta_app/lib/core/network/api_client.dart @@ -0,0 +1,34 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../constants/auth_constants.dart'; +import '../storage/secure_storage.dart'; + +final apiClientProvider = Provider((ref) { + final baseUrl = dotenv.env['API_BASE_URL'] ?? 'http://10.0.2.2:8000'; + final secureStorage = ref.read(secureStorageProvider); + + final dio = Dio( + BaseOptions( + baseUrl: baseUrl, + connectTimeout: const Duration(seconds: 15), + receiveTimeout: const Duration(seconds: 15), + headers: const {'Content-Type': 'application/json'}, + ), + ); + + dio.interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) async { + final token = await secureStorage.read(key: authTokenStorageKey); + if (token != null && token.isNotEmpty) { + options.headers['Authorization'] = 'Bearer $token'; + } + handler.next(options); + }, + ), + ); + + return dio; +}); diff --git a/recolecta_app/lib/core/services/.gitkeep b/recolecta_app/lib/core/services/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/recolecta_app/lib/core/services/auth_controller.dart b/recolecta_app/lib/core/services/auth_controller.dart new file mode 100644 index 0000000..570432a --- /dev/null +++ b/recolecta_app/lib/core/services/auth_controller.dart @@ -0,0 +1,61 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../models/auth_state.dart'; +import 'auth_service.dart'; + +final authControllerProvider = AsyncNotifierProvider( + AuthController.new, +); + +class AuthController extends AsyncNotifier { + @override + Future build() async { + final session = await ref.read(authServiceProvider).restoreSession(); + if (session == null) { + return const AuthState.unauthenticated(); + } + + return AuthState.authenticated( + token: session.token, + userRole: session.userRole, + routeId: session.routeId, + ); + } + + Future login({required String email, required String password}) async { + state = const AsyncLoading(); + final session = await ref + .read(authServiceProvider) + .login(email: email, password: password); + state = AsyncData( + AuthState.authenticated( + token: session.token, + userRole: session.userRole, + routeId: session.routeId, + ), + ); + } + + Future register({ + required String email, + required String phone, + required String password, + }) async { + state = const AsyncLoading(); + final session = await ref + .read(authServiceProvider) + .register(email: email, phone: phone, password: password); + state = AsyncData( + AuthState.authenticated( + token: session.token, + userRole: session.userRole, + routeId: session.routeId, + ), + ); + } + + Future logout() async { + await ref.read(authServiceProvider).logout(); + state = const AsyncData(AuthState.unauthenticated()); + } +} diff --git a/recolecta_app/lib/core/services/auth_service.dart b/recolecta_app/lib/core/services/auth_service.dart new file mode 100644 index 0000000..03f52c8 --- /dev/null +++ b/recolecta_app/lib/core/services/auth_service.dart @@ -0,0 +1,149 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../constants/auth_constants.dart'; +import '../models/auth_session.dart'; +import '../network/api_client.dart'; +import '../storage/secure_storage.dart'; + +final authServiceProvider = Provider((ref) { + return AuthService( + apiClient: ref.read(apiClientProvider), + secureStorage: ref.read(secureStorageProvider), + ); +}); + +class AuthService { + AuthService({ + required Dio apiClient, + required FlutterSecureStorage secureStorage, + }) : _apiClient = apiClient, + _secureStorage = secureStorage; + + final Dio _apiClient; + final FlutterSecureStorage _secureStorage; + + Future restoreSession() async { + final token = await _secureStorage.read(key: authTokenStorageKey); + if (token == null || token.isEmpty) { + return null; + } + + return AuthSession( + token: token, + userRole: await _secureStorage.read(key: authUserRoleStorageKey), + routeId: await _secureStorage.read(key: authRouteIdStorageKey), + ); + } + + Future register({ + required String email, + required String phone, + required String password, + }) { + return _authenticate( + path: '/auth/register', + payload: { + 'email': email, + 'phone': phone, + 'password': password, + }, + ); + } + + Future login({required String email, required String password}) { + return _authenticate( + path: '/auth/login', + payload: {'email': email, 'password': password}, + ); + } + + Future logout() { + return Future.wait(>[ + _secureStorage.delete(key: authTokenStorageKey), + _secureStorage.delete(key: authUserRoleStorageKey), + _secureStorage.delete(key: authRouteIdStorageKey), + ]).then((_) {}); + } + + Future _authenticate({ + required String path, + required Map payload, + }) async { + final response = await _apiClient.post>( + path, + data: payload, + ); + + final session = _extractSession(response.data); + if (session == null || session.token.isEmpty) { + throw StateError('El backend no devolvió un JWT.'); + } + + await _secureStorage.write(key: authTokenStorageKey, value: session.token); + await _writeOptionalString(authUserRoleStorageKey, session.userRole); + await _writeOptionalString(authRouteIdStorageKey, session.routeId); + return session; + } + + Future _writeOptionalString(String key, String? value) async { + if (value == null || value.isEmpty) { + await _secureStorage.delete(key: key); + return; + } + + await _secureStorage.write(key: key, value: value); + } + + AuthSession? _extractSession(Map? data) { + if (data == null) { + return null; + } + + final dataMap = data['data'] is Map + ? data['data'] as Map + : data; + + final token = _pickString([ + dataMap['access_token'], + dataMap['token'], + dataMap['jwt'], + data['access_token'], + data['token'], + data['jwt'], + ]); + + if (token == null || token.isEmpty) { + return null; + } + + return AuthSession( + token: token, + userRole: _pickString([ + dataMap['userRole'], + dataMap['user_role'], + dataMap['role'], + data['userRole'], + data['user_role'], + data['role'], + ]), + routeId: _pickString([ + dataMap['routeId'], + dataMap['route_id'], + data['routeId'], + data['route_id'], + ]), + ); + } + + String? _pickString(Iterable candidates) { + for (final candidate in candidates) { + if (candidate is String && candidate.isNotEmpty) { + return candidate; + } + } + + return null; + } +} diff --git a/recolecta_app/lib/core/services/colonias_service.dart b/recolecta_app/lib/core/services/colonias_service.dart new file mode 100644 index 0000000..990aa80 --- /dev/null +++ b/recolecta_app/lib/core/services/colonias_service.dart @@ -0,0 +1,39 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../models/colonia.dart'; +import '../network/api_client.dart'; + +final coloniasServiceProvider = Provider((ref) { + return ColoniasService(ref.read(apiClientProvider)); +}); + +class ColoniasService { + ColoniasService(this._apiClient); + + final Dio _apiClient; + + Future> getColonias() async { + final response = await _apiClient.get('/colonias'); + final data = response.data; + + if (data == null) { + return const []; + } + + final rawList = switch (data) { + List value => value, + Map value when value['data'] is List => + value['data'] as List, + Map value when value['colonias'] is List => + value['colonias'] as List, + _ => const [], + }; + + return rawList + .whereType>() + .map(Colonia.fromJson) + .where((colonia) => colonia.id.isNotEmpty && colonia.nombre.isNotEmpty) + .toList(growable: false); + } +} diff --git a/recolecta_app/lib/core/storage/.gitkeep b/recolecta_app/lib/core/storage/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/recolecta_app/lib/core/storage/secure_storage.dart b/recolecta_app/lib/core/storage/secure_storage.dart new file mode 100644 index 0000000..6153bf6 --- /dev/null +++ b/recolecta_app/lib/core/storage/secure_storage.dart @@ -0,0 +1,6 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +final secureStorageProvider = Provider((ref) { + return const FlutterSecureStorage(); +}); diff --git a/recolecta_app/lib/core/theme/.gitkeep b/recolecta_app/lib/core/theme/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/recolecta_app/lib/core/utils/.gitkeep b/recolecta_app/lib/core/utils/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/recolecta_app/lib/features/addresses/.gitkeep b/recolecta_app/lib/features/addresses/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/recolecta_app/lib/features/addresses/colonias_provider.dart b/recolecta_app/lib/features/addresses/colonias_provider.dart new file mode 100644 index 0000000..a33887c --- /dev/null +++ b/recolecta_app/lib/features/addresses/colonias_provider.dart @@ -0,0 +1,8 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/models/colonia.dart'; +import '../../core/services/colonias_service.dart'; + +final coloniasProvider = FutureProvider>((ref) async { + return ref.read(coloniasServiceProvider).getColonias(); +}); diff --git a/recolecta_app/lib/features/addresses/colonias_selector.dart b/recolecta_app/lib/features/addresses/colonias_selector.dart new file mode 100644 index 0000000..3846449 --- /dev/null +++ b/recolecta_app/lib/features/addresses/colonias_selector.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/models/colonia.dart'; +import 'colonias_provider.dart'; + +class ColoniasSelector extends ConsumerWidget { + const ColoniasSelector({ + super.key, + required this.onChanged, + this.initialValue, + this.labelText = 'Colonia', + }); + + final ValueChanged onChanged; + final Colonia? initialValue; + final String labelText; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final coloniasAsync = ref.watch(coloniasProvider); + + return coloniasAsync.when( + loading: () => const Card( + child: Padding( + padding: EdgeInsets.all(16), + child: Row( + children: [ + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 12), + Text('Cargando colonias...'), + ], + ), + ), + ), + error: (error, stackTrace) => _StateCard( + icon: Icons.error_outline, + title: 'No se pudieron cargar las colonias', + message: error.toString(), + actionLabel: 'Reintentar', + onAction: () => ref.invalidate(coloniasProvider), + ), + data: (colonias) { + if (colonias.isEmpty) { + return const _StateCard( + icon: Icons.inbox_outlined, + title: 'Sin colonias disponibles', + message: 'El backend no devolvió colonias todavía.', + ); + } + + return DropdownButtonFormField( + value: initialValue, + decoration: InputDecoration(labelText: labelText), + items: colonias + .map( + (colonia) => DropdownMenuItem( + value: colonia, + child: Text( + colonia.horarioEstimado == null || + colonia.horarioEstimado!.isEmpty + ? colonia.nombre + : '${colonia.nombre} · ${colonia.horarioEstimado}', + ), + ), + ) + .toList(growable: false), + onChanged: (value) { + if (value != null) { + onChanged(value); + } + }, + ); + }, + ); + } +} + +class _StateCard extends StatelessWidget { + const _StateCard({ + required this.icon, + required this.title, + required this.message, + this.actionLabel, + this.onAction, + }); + + final IconData icon; + final String title; + final String message; + final String? actionLabel; + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon), + const SizedBox(height: 12), + Text(title, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 6), + Text(message), + if (actionLabel != null && onAction != null) ...[ + const SizedBox(height: 12), + TextButton(onPressed: onAction, child: Text(actionLabel!)), + ], + ], + ), + ), + ); + } +} diff --git a/recolecta_app/lib/features/addresses/new_address_page.dart b/recolecta_app/lib/features/addresses/new_address_page.dart new file mode 100644 index 0000000..5398699 --- /dev/null +++ b/recolecta_app/lib/features/addresses/new_address_page.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/models/address.dart'; +import '../../core/models/colonia.dart'; +import 'colonias_selector.dart'; + +class NewAddressPage extends ConsumerStatefulWidget { + const NewAddressPage({super.key}); + + @override + ConsumerState createState() => _NewAddressPageState(); +} + +class _NewAddressPageState extends ConsumerState { + final _formKey = GlobalKey(); + final _labelController = TextEditingController(); + final _streetController = TextEditingController(); + Colonia? _selectedColonia; + + @override + void dispose() { + _labelController.dispose(); + _streetController.dispose(); + super.dispose(); + } + + void _saveAddress() { + if (!(_formKey.currentState?.validate() ?? false)) { + return; + } + if (_selectedColonia == null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Selecciona una colonia'))); + return; + } + + final address = AddressModel( + label: _labelController.text.trim(), + street: _streetController.text.trim(), + colonia: _selectedColonia!, + ); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Domicilio listo: ${address.toJson()}')), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Nuevo domicilio')), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(24), + children: [ + Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + controller: _labelController, + decoration: const InputDecoration( + labelText: 'Etiqueta', + hintText: 'Casa, trabajo, etc.', + ), + validator: (value) => + (value == null || value.trim().isEmpty) + ? 'Ingresa una etiqueta' + : null, + ), + const SizedBox(height: 16), + TextFormField( + controller: _streetController, + decoration: const InputDecoration( + labelText: 'Calle', + hintText: 'Av. Principal 123', + ), + validator: (value) => + (value == null || value.trim().isEmpty) + ? 'Ingresa la calle' + : null, + ), + const SizedBox(height: 16), + ColoniasSelector( + labelText: 'Colonia', + initialValue: _selectedColonia, + onChanged: (colonia) { + setState(() => _selectedColonia = colonia); + }, + ), + const SizedBox(height: 24), + SizedBox( + height: 52, + child: FilledButton( + onPressed: _saveAddress, + child: const Text('Guardar domicilio'), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/recolecta_app/lib/features/admin/.gitkeep b/recolecta_app/lib/features/admin/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/recolecta_app/lib/features/auth/.gitkeep b/recolecta_app/lib/features/auth/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/recolecta_app/lib/features/auth/login_page.dart b/recolecta_app/lib/features/auth/login_page.dart new file mode 100644 index 0000000..d3df2ad --- /dev/null +++ b/recolecta_app/lib/features/auth/login_page.dart @@ -0,0 +1,164 @@ +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'; + +class LoginPage extends ConsumerStatefulWidget { + const LoginPage({super.key}); + + @override + ConsumerState createState() => _LoginPageState(); +} + +class _LoginPageState extends ConsumerState { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + bool _obscurePassword = true; + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _submit() async { + if (!(_formKey.currentState?.validate() ?? false)) { + return; + } + + try { + await ref + .read(authControllerProvider.notifier) + .login( + email: _emailController.text.trim(), + password: _passwordController.text, + ); + if (!mounted) { + return; + } + final authState = ref.read(authControllerProvider).asData?.value; + if (authState?.userRole == 'admin') { + context.go('/admin'); + return; + } + if (authState?.userRole == 'driver') { + context.go('/driver'); + return; + } + final routeId = authState?.routeId; + if (routeId != null && routeId.isNotEmpty) { + context.go('/home?routeId=$routeId'); + return; + } + context.go('/home'); + } catch (error) { + if (!mounted) { + return; + } + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error.toString()))); + } + } + + @override + Widget build(BuildContext context) { + final authState = ref.watch(authControllerProvider); + final loading = authState.isLoading; + + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 12), + const Icon(Icons.delete_outline_rounded, size: 54), + const SizedBox(height: 16), + Text( + 'Recolecta', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), + Text( + 'Accede para ver solo tu ruta asignada.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 28), + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration( + labelText: 'Correo electrónico', + hintText: 'tu@correo.com', + ), + validator: (value) => + (value == null || value.trim().isEmpty) + ? 'Ingresa tu correo' + : null, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + obscureText: _obscurePassword, + decoration: InputDecoration( + labelText: 'Contraseña', + hintText: '••••••••', + suffixIcon: IconButton( + onPressed: () => setState( + () => _obscurePassword = !_obscurePassword, + ), + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + ), + ), + validator: (value) => (value == null || value.length < 6) + ? 'La contraseña debe tener al menos 6 caracteres' + : null, + ), + const SizedBox(height: 24), + SizedBox( + height: 52, + child: FilledButton( + onPressed: loading ? null : _submit, + child: loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Text('Entrar'), + ), + ), + const SizedBox(height: 16), + TextButton( + onPressed: () => context.go('/register'), + child: const Text('Crear cuenta'), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/recolecta_app/lib/features/auth/register_page.dart b/recolecta_app/lib/features/auth/register_page.dart new file mode 100644 index 0000000..869b123 --- /dev/null +++ b/recolecta_app/lib/features/auth/register_page.dart @@ -0,0 +1,200 @@ +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'; + +class RegisterPage extends ConsumerStatefulWidget { + const RegisterPage({super.key}); + + @override + ConsumerState createState() => _RegisterPageState(); +} + +class _RegisterPageState extends ConsumerState { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _phoneController = TextEditingController(); + final _passwordController = TextEditingController(); + final _confirmPasswordController = TextEditingController(); + bool _obscurePassword = true; + + @override + void dispose() { + _emailController.dispose(); + _phoneController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _submit() async { + if (!(_formKey.currentState?.validate() ?? false)) { + return; + } + + try { + await ref + .read(authControllerProvider.notifier) + .register( + email: _emailController.text.trim(), + phone: _phoneController.text.trim(), + password: _passwordController.text, + ); + if (!mounted) { + return; + } + final authState = ref.read(authControllerProvider).asData?.value; + if (authState?.userRole == 'admin') { + context.go('/admin'); + return; + } + if (authState?.userRole == 'driver') { + context.go('/driver'); + return; + } + final routeId = authState?.routeId; + if (routeId != null && routeId.isNotEmpty) { + context.go('/home?routeId=$routeId'); + return; + } + context.go('/home'); + } catch (error) { + if (!mounted) { + return; + } + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error.toString()))); + } + } + + @override + Widget build(BuildContext context) { + final authState = ref.watch(authControllerProvider); + final loading = authState.isLoading; + + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 12), + const Icon(Icons.person_add_alt_1_outlined, size: 54), + const SizedBox(height: 16), + Text( + 'Crear cuenta', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), + Text( + 'Registra tu correo, teléfono y contraseña para continuar.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 28), + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration( + labelText: 'Correo electrónico', + hintText: 'tu@correo.com', + ), + validator: (value) => + (value == null || value.trim().isEmpty) + ? 'Ingresa tu correo' + : null, + ), + const SizedBox(height: 16), + TextFormField( + controller: _phoneController, + keyboardType: TextInputType.phone, + decoration: const InputDecoration( + labelText: 'Teléfono', + hintText: '+52 461 123 4567', + ), + validator: (value) => + (value == null || value.trim().isEmpty) + ? 'Ingresa tu teléfono' + : null, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + obscureText: _obscurePassword, + decoration: InputDecoration( + labelText: 'Contraseña', + hintText: '••••••••', + suffixIcon: IconButton( + onPressed: () => setState( + () => _obscurePassword = !_obscurePassword, + ), + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + ), + ), + validator: (value) => (value == null || value.length < 6) + ? 'La contraseña debe tener al menos 6 caracteres' + : null, + ), + const SizedBox(height: 16), + TextFormField( + controller: _confirmPasswordController, + obscureText: _obscurePassword, + decoration: const InputDecoration( + labelText: 'Confirmar contraseña', + hintText: '••••••••', + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Confirma tu contraseña'; + } + if (value != _passwordController.text) { + return 'Las contraseñas no coinciden'; + } + return null; + }, + ), + const SizedBox(height: 24), + SizedBox( + height: 52, + child: FilledButton( + onPressed: loading ? null : _submit, + child: loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Text('Registrarme'), + ), + ), + const SizedBox(height: 16), + TextButton( + onPressed: () => context.go('/login'), + child: const Text('Ya tengo cuenta'), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/recolecta_app/lib/features/driver/.gitkeep b/recolecta_app/lib/features/driver/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/recolecta_app/lib/features/eta/.gitkeep b/recolecta_app/lib/features/eta/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/recolecta_app/lib/features/feedback/.gitkeep b/recolecta_app/lib/features/feedback/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/recolecta_app/lib/features/notifications/.gitkeep b/recolecta_app/lib/features/notifications/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/recolecta_app/lib/features/separation_guide/.gitkeep b/recolecta_app/lib/features/separation_guide/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/recolecta_app/lib/shared/.gitkeep b/recolecta_app/lib/shared/.gitkeep new file mode 100644 index 0000000..e69de29