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

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("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

View File

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

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

View File

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

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