bLOQUE p1 BACKEND Y SEGURIDAD, AUTENTICACION CON SUPABASE. jwt. RBAC CRUD

This commit is contained in:
shinra32
2026-05-22 19:45:05 -06:00
parent 5dc8390855
commit fc28333e3f
52 changed files with 1605 additions and 109 deletions

View File

View 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
View 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,
)

View 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()

View File

@@ -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

View File

View 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
View 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

View 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)

View 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);

View File

@@ -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)

View File

View 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

View 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

View 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

View File

@@ -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

View File

@@ -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"}

View File

@@ -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

View File

@@ -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),

View 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}',
);
}

View File

View File

@@ -0,0 +1,3 @@
const String authTokenStorageKey = 'auth_jwt';
const String authUserRoleStorageKey = 'auth_user_role';
const String authRouteIdStorageKey = 'auth_route_id';

View File

View 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,
};
}
}

View 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';
}

View 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;
}

View 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(),
);
}
}

View File

View 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;
});

View File

View 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());
}
}

View 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;
}
}

View 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);
}
}

View File

View 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();
});

View File

View File

View 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();
});

View 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!)),
],
],
),
),
);
}
}

View 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'),
),
),
],
),
),
],
),
),
);
}
}

View File

View 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'),
),
],
),
),
),
),
),
),
);
}
}

View 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'),
),
],
),
),
),
),
),
),
);
}
}

View File

View File