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.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
|
||||
|
||||
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("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
|
||||
|
||||
@@ -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)
|
||||
notifications.init_firebase(settings.FIREBASE_CREDENTIALS_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')
|
||||
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."
|
||||
"message": "Backend operativo. Regla innegociable #1: NUNCA se devuelven coordenadas al ciudadano.",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
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
|
||||
uvicorn[standard]==0.29.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
|
||||
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
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
|
||||
@@ -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<void>((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<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();
|
||||
});
|
||||
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<GoRouter>((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: <RouteBase>[
|
||||
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),
|
||||
|
||||
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