bLOQUE p1 BACKEND Y SEGURIDAD, AUTENTICACION CON SUPABASE. jwt. RBAC CRUD
This commit is contained in:
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
94
backend/app/api/addresses.py
Normal file
94
backend/app/api/addresses.py
Normal file
@@ -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
|
||||||
90
backend/app/api/auth.py
Normal file
90
backend/app/api/auth.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
11
backend/app/api/colonias.py
Normal file
11
backend/app/api/colonias.py
Normal file
@@ -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()
|
||||||
@@ -5,11 +5,6 @@ from app.services import simulation
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/colonias")
|
|
||||||
def list_colonias():
|
|
||||||
return simulation.get_colonias()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/eta")
|
@router.get("/eta")
|
||||||
def get_eta(colonia: Optional[str] = None, routeId: Optional[str] = None):
|
def get_eta(colonia: Optional[str] = None, routeId: Optional[str] = None):
|
||||||
# Resolver routeId a partir de colonia si es necesario
|
# Resolver routeId a partir de colonia si es necesario
|
||||||
|
|||||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
21
backend/app/core/config.py
Normal file
21
backend/app/core/config.py
Normal file
@@ -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()
|
||||||
46
backend/app/core/deps.py
Normal file
46
backend/app/core/deps.py
Normal file
@@ -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
|
||||||
8
backend/app/core/supabase_client.py
Normal file
8
backend/app/core/supabase_client.py
Normal file
@@ -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)
|
||||||
146
backend/app/db/rls_policies.sql
Normal file
146
backend/app/db/rls_policies.sql
Normal file
@@ -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);
|
||||||
@@ -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)
|
|
||||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
21
backend/app/schemas/addresses.py
Normal file
21
backend/app/schemas/addresses.py
Normal file
@@ -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
|
||||||
22
backend/app/schemas/auth.py
Normal file
22
backend/app/schemas/auth.py
Normal file
@@ -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
|
||||||
8
backend/app/schemas/colonias.py
Normal file
8
backend/app/schemas/colonias.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ColoniaResponse(BaseModel):
|
||||||
|
colonia: str
|
||||||
|
routeId: str
|
||||||
|
turno: str | None = None
|
||||||
|
horario_estimado: str | None = None
|
||||||
@@ -23,8 +23,11 @@ def init_firebase(cred_path: str):
|
|||||||
print(f"ADVERTENCIA: Credenciales no encontradas en '{cred_path}'.")
|
print(f"ADVERTENCIA: Credenciales no encontradas en '{cred_path}'.")
|
||||||
print("Las notificaciones se ejecutarán en modo SIMULADO (solo consola).")
|
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)."""
|
"""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:
|
if not _firebase_initialized:
|
||||||
print(f"[MOCK PUSH] -> Topic: {topic} | Título: '{title}' | Mensaje: '{body}'")
|
print(f"[MOCK PUSH] -> Topic: {topic} | Título: '{title}' | Mensaje: '{body}'")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,58 +1,72 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
import os
|
from starlette.middleware.cors import CORSMiddleware
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
from app.api.eta import router as eta_router
|
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
|
from app.services import simulation, notifications
|
||||||
|
|
||||||
scheduler = AsyncIOScheduler()
|
scheduler = AsyncIOScheduler()
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""
|
|
||||||
Maneja el ciclo de vida de la aplicación.
|
|
||||||
"""
|
|
||||||
print("Iniciando aplicación: Backend Sistema de Recolección...")
|
print("Iniciando aplicación: Backend Sistema de Recolección...")
|
||||||
|
|
||||||
# 1. Cargar datos de simulación
|
|
||||||
simulation.load_data()
|
simulation.load_data()
|
||||||
simulation.start_simulation_state()
|
simulation.start_simulation_state()
|
||||||
|
|
||||||
# 2. Inicializar Firebase (o Mock si no hay credenciales)
|
notifications.init_firebase(settings.FIREBASE_CREDENTIALS_PATH)
|
||||||
# Ruta relativa correcta cuando se ejecuta desde la carpeta /backend
|
|
||||||
cred_path = os.environ.get("FIREBASE_CREDENTIALS_PATH", "secrets/firebase-adminsdk.json")
|
scheduler.add_job(
|
||||||
notifications.init_firebase(cred_path)
|
simulation.tick,
|
||||||
|
"interval",
|
||||||
# 3. Arrancar el scheduler de simulación
|
seconds=settings.SIMULATION_TICK_SECONDS,
|
||||||
tick_seconds = int(os.environ.get("SIMULATION_TICK_SECONDS", 15))
|
id="simulation_tick",
|
||||||
scheduler.add_job(simulation.tick, 'interval', seconds=tick_seconds, id='simulation_tick')
|
)
|
||||||
scheduler.start()
|
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
|
yield
|
||||||
|
|
||||||
print("Apagando aplicación y deteniendo simulador...")
|
print("Apagando aplicación y deteniendo simulador...")
|
||||||
scheduler.shutdown()
|
scheduler.shutdown()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
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.",
|
description="Backend para el sistema de recolección de residuos con privacidad por diseño.",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
lifespan=lifespan
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Incluir routers de la API
|
# CORS — necesario para el simulador web y el cliente Flutter
|
||||||
app.include_router(eta_router)
|
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("/")
|
@app.get("/")
|
||||||
def read_root():
|
def read_root():
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"message": "Backend operativo. Regla Innegociable 1: NUNCA se devuelven coordenadas del camión al ciudadano."
|
"message": "Backend operativo. Regla innegociable #1: NUNCA se devuelven coordenadas al ciudadano.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health_check():
|
def health_check():
|
||||||
return {"status": "healthy"}
|
return {"status": "healthy"}
|
||||||
|
|||||||
@@ -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
|
fastapi==0.111.0
|
||||||
uvicorn[standard]==0.29.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
|
sqlalchemy==2.0.30
|
||||||
psycopg2-binary==2.9.9
|
psycopg2-binary==2.9.9
|
||||||
apscheduler==3.10.4
|
|
||||||
python-jose[cryptography]==3.3.0
|
python-jose[cryptography]==3.3.0
|
||||||
passlib[bcrypt]==1.7.4
|
passlib[bcrypt]==1.7.4
|
||||||
pydantic-settings==2.2.1
|
|
||||||
supabase==2.4.5
|
|
||||||
firebase-admin==6.5.0
|
|
||||||
@@ -1,69 +1,61 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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: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<void>((ref) async {
|
import '../core/network/api_client.dart';
|
||||||
await dotenv.load(fileName: 'assets/.env');
|
import '../core/services/auth_controller.dart';
|
||||||
|
import '../core/storage/secure_storage.dart';
|
||||||
// Inicializar Firebase (si hay DefaultFirebaseOptions, úsalas; sino, intenta initializeApp() y espera que haya google-services/Info.plist)
|
import 'bootstrap.dart' as bootstrap;
|
||||||
final FirebaseOptions? options = DefaultFirebaseOptions.currentPlatform;
|
import '../features/auth/login_page.dart';
|
||||||
if (options != null) {
|
import '../features/auth/register_page.dart';
|
||||||
await Firebase.initializeApp(options: options);
|
import '../features/addresses/new_address_page.dart';
|
||||||
} else {
|
|
||||||
await Firebase.initializeApp();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Registrar handler para mensajes en background
|
|
||||||
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handler top-level requerido por FCM
|
|
||||||
Future<void> _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<Dio>((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 <String, dynamic>{'Content-Type': 'application/json'},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
final secureStorageProvider = Provider<FlutterSecureStorage>((ref) {
|
|
||||||
return const FlutterSecureStorage();
|
|
||||||
});
|
|
||||||
|
|
||||||
final routerProvider = Provider<GoRouter>((ref) {
|
final routerProvider = Provider<GoRouter>((ref) {
|
||||||
|
final authSnapshot = ref.watch(authControllerProvider);
|
||||||
|
final isAuthenticated = authSnapshot.asData?.value.isAuthenticated ?? false;
|
||||||
|
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
initialLocation: '/home',
|
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: <RouteBase>[
|
routes: <RouteBase>[
|
||||||
|
GoRoute(
|
||||||
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
|
builder: (context, state) => const LoginPage(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/register',
|
||||||
|
name: 'register',
|
||||||
|
builder: (context, state) => const RegisterPage(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/home',
|
path: '/home',
|
||||||
name: 'home',
|
name: 'home',
|
||||||
builder: (context, state) => const HomePage(),
|
builder: (context, state) => const HomePage(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/addresses/new',
|
||||||
|
name: 'addresses-new',
|
||||||
|
builder: (context, state) => const NewAddressPage(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/status',
|
path: '/status',
|
||||||
name: 'status',
|
name: 'status',
|
||||||
@@ -78,9 +70,9 @@ class RecolectaApp extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
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(
|
loading: () => const MaterialApp(
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
home: BootstrapLoadingPage(),
|
home: BootstrapLoadingPage(),
|
||||||
@@ -154,11 +146,24 @@ class HomePage extends ConsumerWidget {
|
|||||||
final dio = ref.read(apiClientProvider);
|
final dio = ref.read(apiClientProvider);
|
||||||
final storage = ref.read(secureStorageProvider);
|
final storage = ref.read(secureStorageProvider);
|
||||||
final baseUrl = dio.options.baseUrl;
|
final baseUrl = dio.options.baseUrl;
|
||||||
|
final authState = ref.watch(authControllerProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Recolecta'),
|
title: const Text('Recolecta'),
|
||||||
actions: [
|
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(
|
IconButton(
|
||||||
onPressed: () => context.goNamed('status'),
|
onPressed: () => context.goNamed('status'),
|
||||||
icon: const Icon(Icons.route),
|
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.',
|
'La app ya carga .env, Riverpod y GoRouter para la base del MVP.',
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
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),
|
const SizedBox(height: 24),
|
||||||
_InfoCard(title: 'API base URL', value: baseUrl, icon: Icons.cloud),
|
_InfoCard(title: 'API base URL', value: baseUrl, icon: Icons.cloud),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|||||||
36
recolecta_app/lib/app/bootstrap.dart
Normal file
36
recolecta_app/lib/app/bootstrap.dart
Normal file
@@ -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<void>((ref) async {
|
||||||
|
await dotenv.load(fileName: 'assets/.env');
|
||||||
|
await _initializeFirebase();
|
||||||
|
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> _initializeFirebase() async {
|
||||||
|
try {
|
||||||
|
await Firebase.initializeApp(
|
||||||
|
options: DefaultFirebaseOptions.currentPlatform,
|
||||||
|
);
|
||||||
|
} on UnsupportedError {
|
||||||
|
await Firebase.initializeApp();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@pragma('vm:entry-point')
|
||||||
|
Future<void> _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}',
|
||||||
|
);
|
||||||
|
}
|
||||||
0
recolecta_app/lib/core/auth/.gitkeep
Normal file
0
recolecta_app/lib/core/auth/.gitkeep
Normal file
0
recolecta_app/lib/core/constants/.gitkeep
Normal file
0
recolecta_app/lib/core/constants/.gitkeep
Normal file
3
recolecta_app/lib/core/constants/auth_constants.dart
Normal file
3
recolecta_app/lib/core/constants/auth_constants.dart
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
const String authTokenStorageKey = 'auth_jwt';
|
||||||
|
const String authUserRoleStorageKey = 'auth_user_role';
|
||||||
|
const String authRouteIdStorageKey = 'auth_route_id';
|
||||||
0
recolecta_app/lib/core/models/.gitkeep
Normal file
0
recolecta_app/lib/core/models/.gitkeep
Normal file
22
recolecta_app/lib/core/models/address.dart
Normal file
22
recolecta_app/lib/core/models/address.dart
Normal file
@@ -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<String, dynamic> toJson() {
|
||||||
|
return <String, dynamic>{
|
||||||
|
'label': label,
|
||||||
|
'calle': street,
|
||||||
|
'colonia': colonia.nombre,
|
||||||
|
'route_id': colonia.routeId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
11
recolecta_app/lib/core/models/auth_session.dart
Normal file
11
recolecta_app/lib/core/models/auth_session.dart
Normal file
@@ -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';
|
||||||
|
}
|
||||||
28
recolecta_app/lib/core/models/auth_state.dart
Normal file
28
recolecta_app/lib/core/models/auth_state.dart
Normal file
@@ -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;
|
||||||
|
}
|
||||||
37
recolecta_app/lib/core/models/colonia.dart
Normal file
37
recolecta_app/lib/core/models/colonia.dart
Normal file
@@ -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<String, dynamic> 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
0
recolecta_app/lib/core/network/.gitkeep
Normal file
0
recolecta_app/lib/core/network/.gitkeep
Normal file
34
recolecta_app/lib/core/network/api_client.dart
Normal file
34
recolecta_app/lib/core/network/api_client.dart
Normal file
@@ -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<Dio>((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 <String, dynamic>{'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;
|
||||||
|
});
|
||||||
0
recolecta_app/lib/core/services/.gitkeep
Normal file
0
recolecta_app/lib/core/services/.gitkeep
Normal file
61
recolecta_app/lib/core/services/auth_controller.dart
Normal file
61
recolecta_app/lib/core/services/auth_controller.dart
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../models/auth_state.dart';
|
||||||
|
import 'auth_service.dart';
|
||||||
|
|
||||||
|
final authControllerProvider = AsyncNotifierProvider<AuthController, AuthState>(
|
||||||
|
AuthController.new,
|
||||||
|
);
|
||||||
|
|
||||||
|
class AuthController extends AsyncNotifier<AuthState> {
|
||||||
|
@override
|
||||||
|
Future<AuthState> 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<void> login({required String email, required String password}) async {
|
||||||
|
state = const AsyncLoading<AuthState>();
|
||||||
|
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<void> register({
|
||||||
|
required String email,
|
||||||
|
required String phone,
|
||||||
|
required String password,
|
||||||
|
}) async {
|
||||||
|
state = const AsyncLoading<AuthState>();
|
||||||
|
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<void> logout() async {
|
||||||
|
await ref.read(authServiceProvider).logout();
|
||||||
|
state = const AsyncData(AuthState.unauthenticated());
|
||||||
|
}
|
||||||
|
}
|
||||||
149
recolecta_app/lib/core/services/auth_service.dart
Normal file
149
recolecta_app/lib/core/services/auth_service.dart
Normal file
@@ -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<AuthService>((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<AuthSession?> 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<AuthSession> register({
|
||||||
|
required String email,
|
||||||
|
required String phone,
|
||||||
|
required String password,
|
||||||
|
}) {
|
||||||
|
return _authenticate(
|
||||||
|
path: '/auth/register',
|
||||||
|
payload: <String, dynamic>{
|
||||||
|
'email': email,
|
||||||
|
'phone': phone,
|
||||||
|
'password': password,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AuthSession> login({required String email, required String password}) {
|
||||||
|
return _authenticate(
|
||||||
|
path: '/auth/login',
|
||||||
|
payload: <String, dynamic>{'email': email, 'password': password},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> logout() {
|
||||||
|
return Future.wait(<Future<void>>[
|
||||||
|
_secureStorage.delete(key: authTokenStorageKey),
|
||||||
|
_secureStorage.delete(key: authUserRoleStorageKey),
|
||||||
|
_secureStorage.delete(key: authRouteIdStorageKey),
|
||||||
|
]).then((_) {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AuthSession> _authenticate({
|
||||||
|
required String path,
|
||||||
|
required Map<String, dynamic> payload,
|
||||||
|
}) async {
|
||||||
|
final response = await _apiClient.post<Map<String, dynamic>>(
|
||||||
|
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<void> _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<String, dynamic>? data) {
|
||||||
|
if (data == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final dataMap = data['data'] is Map<String, dynamic>
|
||||||
|
? data['data'] as Map<String, dynamic>
|
||||||
|
: data;
|
||||||
|
|
||||||
|
final token = _pickString(<dynamic>[
|
||||||
|
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(<dynamic>[
|
||||||
|
dataMap['userRole'],
|
||||||
|
dataMap['user_role'],
|
||||||
|
dataMap['role'],
|
||||||
|
data['userRole'],
|
||||||
|
data['user_role'],
|
||||||
|
data['role'],
|
||||||
|
]),
|
||||||
|
routeId: _pickString(<dynamic>[
|
||||||
|
dataMap['routeId'],
|
||||||
|
dataMap['route_id'],
|
||||||
|
data['routeId'],
|
||||||
|
data['route_id'],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _pickString(Iterable<dynamic> candidates) {
|
||||||
|
for (final candidate in candidates) {
|
||||||
|
if (candidate is String && candidate.isNotEmpty) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
recolecta_app/lib/core/services/colonias_service.dart
Normal file
39
recolecta_app/lib/core/services/colonias_service.dart
Normal file
@@ -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<ColoniasService>((ref) {
|
||||||
|
return ColoniasService(ref.read(apiClientProvider));
|
||||||
|
});
|
||||||
|
|
||||||
|
class ColoniasService {
|
||||||
|
ColoniasService(this._apiClient);
|
||||||
|
|
||||||
|
final Dio _apiClient;
|
||||||
|
|
||||||
|
Future<List<Colonia>> getColonias() async {
|
||||||
|
final response = await _apiClient.get<dynamic>('/colonias');
|
||||||
|
final data = response.data;
|
||||||
|
|
||||||
|
if (data == null) {
|
||||||
|
return const <Colonia>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
final rawList = switch (data) {
|
||||||
|
List<dynamic> value => value,
|
||||||
|
Map<String, dynamic> value when value['data'] is List<dynamic> =>
|
||||||
|
value['data'] as List<dynamic>,
|
||||||
|
Map<String, dynamic> value when value['colonias'] is List<dynamic> =>
|
||||||
|
value['colonias'] as List<dynamic>,
|
||||||
|
_ => const <dynamic>[],
|
||||||
|
};
|
||||||
|
|
||||||
|
return rawList
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(Colonia.fromJson)
|
||||||
|
.where((colonia) => colonia.id.isNotEmpty && colonia.nombre.isNotEmpty)
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
0
recolecta_app/lib/core/storage/.gitkeep
Normal file
0
recolecta_app/lib/core/storage/.gitkeep
Normal file
6
recolecta_app/lib/core/storage/secure_storage.dart
Normal file
6
recolecta_app/lib/core/storage/secure_storage.dart
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
|
final secureStorageProvider = Provider<FlutterSecureStorage>((ref) {
|
||||||
|
return const FlutterSecureStorage();
|
||||||
|
});
|
||||||
0
recolecta_app/lib/core/theme/.gitkeep
Normal file
0
recolecta_app/lib/core/theme/.gitkeep
Normal file
0
recolecta_app/lib/core/utils/.gitkeep
Normal file
0
recolecta_app/lib/core/utils/.gitkeep
Normal file
0
recolecta_app/lib/features/addresses/.gitkeep
Normal file
0
recolecta_app/lib/features/addresses/.gitkeep
Normal file
@@ -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<List<Colonia>>((ref) async {
|
||||||
|
return ref.read(coloniasServiceProvider).getColonias();
|
||||||
|
});
|
||||||
120
recolecta_app/lib/features/addresses/colonias_selector.dart
Normal file
120
recolecta_app/lib/features/addresses/colonias_selector.dart
Normal file
@@ -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<Colonia> 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<Colonia>(
|
||||||
|
value: initialValue,
|
||||||
|
decoration: InputDecoration(labelText: labelText),
|
||||||
|
items: colonias
|
||||||
|
.map(
|
||||||
|
(colonia) => DropdownMenuItem<Colonia>(
|
||||||
|
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!)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
110
recolecta_app/lib/features/addresses/new_address_page.dart
Normal file
110
recolecta_app/lib/features/addresses/new_address_page.dart
Normal file
@@ -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<NewAddressPage> createState() => _NewAddressPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NewAddressPageState extends ConsumerState<NewAddressPage> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
0
recolecta_app/lib/features/admin/.gitkeep
Normal file
0
recolecta_app/lib/features/admin/.gitkeep
Normal file
0
recolecta_app/lib/features/auth/.gitkeep
Normal file
0
recolecta_app/lib/features/auth/.gitkeep
Normal file
164
recolecta_app/lib/features/auth/login_page.dart
Normal file
164
recolecta_app/lib/features/auth/login_page.dart
Normal file
@@ -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<LoginPage> createState() => _LoginPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _emailController = TextEditingController();
|
||||||
|
final _passwordController = TextEditingController();
|
||||||
|
bool _obscurePassword = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_emailController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
200
recolecta_app/lib/features/auth/register_page.dart
Normal file
200
recolecta_app/lib/features/auth/register_page.dart
Normal file
@@ -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<RegisterPage> createState() => _RegisterPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RegisterPageState extends ConsumerState<RegisterPage> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
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<void> _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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
0
recolecta_app/lib/features/driver/.gitkeep
Normal file
0
recolecta_app/lib/features/driver/.gitkeep
Normal file
0
recolecta_app/lib/features/eta/.gitkeep
Normal file
0
recolecta_app/lib/features/eta/.gitkeep
Normal file
0
recolecta_app/lib/features/feedback/.gitkeep
Normal file
0
recolecta_app/lib/features/feedback/.gitkeep
Normal file
0
recolecta_app/lib/features/notifications/.gitkeep
Normal file
0
recolecta_app/lib/features/notifications/.gitkeep
Normal file
0
recolecta_app/lib/shared/.gitkeep
Normal file
0
recolecta_app/lib/shared/.gitkeep
Normal file
Reference in New Issue
Block a user