Co-authored-by: eddgranados12 <eddgranados12@users.noreply.github.com>
Co-authored-by: MENDOZA BALLARDO GAEL RICARDO <gael-meb123@users.noreply.github.com> Co-authored-by: Azareth-Tr <Azareth-Tr@users.noreply.github.com> modificacion de las vistas principales para el usuario ciudadano, primer avance para el panel admin
This commit is contained in:
BIN
animations/Recogida_correcta.mp4
Normal file
BIN
animations/Recogida_correcta.mp4
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
animations/info.mp4
Normal file
BIN
animations/info.mp4
Normal file
Binary file not shown.
BIN
animations/saludo.mp4
Normal file
BIN
animations/saludo.mp4
Normal file
Binary file not shown.
@@ -29,18 +29,22 @@ def create_address(
|
|||||||
"""Alta de domicilio. El routeId se deriva automáticamente de la colonia elegida."""
|
"""Alta de domicilio. El routeId se deriva automáticamente de la colonia elegida."""
|
||||||
route_id = _resolve_route_id(body.colonia)
|
route_id = _resolve_route_id(body.colonia)
|
||||||
|
|
||||||
|
insert_data: dict = {
|
||||||
|
"user_id": current_user["user_id"],
|
||||||
|
"label": body.label,
|
||||||
|
"calle": body.calle,
|
||||||
|
"colonia": body.colonia,
|
||||||
|
"route_id": route_id,
|
||||||
|
"verified": False,
|
||||||
|
}
|
||||||
|
if body.lat is not None:
|
||||||
|
insert_data["lat"] = body.lat
|
||||||
|
if body.lng is not None:
|
||||||
|
insert_data["lng"] = body.lng
|
||||||
|
|
||||||
result = (
|
result = (
|
||||||
supabase_admin.table("addresses")
|
supabase_admin.table("addresses")
|
||||||
.insert(
|
.insert(insert_data)
|
||||||
{
|
|
||||||
"user_id": current_user["user_id"],
|
|
||||||
"label": body.label,
|
|
||||||
"calle": body.calle,
|
|
||||||
"colonia": body.colonia,
|
|
||||||
"route_id": route_id,
|
|
||||||
"verified": False,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.execute()
|
.execute()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,34 @@ def _fetch_role(user_id: str) -> str:
|
|||||||
return result.data["role"] if result.data else "citizen"
|
return result.data["role"] if result.data else "citizen"
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_route_for_citizen(user_id: str) -> str | None:
|
||||||
|
"""
|
||||||
|
Busca la primera dirección verificada del ciudadano y devuelve su `route_id`.
|
||||||
|
Devuelve None si no hay dirección verificada.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
res = (
|
||||||
|
supabase_admin.table("addresses")
|
||||||
|
.select("route_id")
|
||||||
|
.eq("user_id", user_id)
|
||||||
|
.eq("verified", True)
|
||||||
|
.limit(1)
|
||||||
|
.maybe_single()
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
if res.data and isinstance(res.data, dict):
|
||||||
|
return res.data.get("route_id")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
||||||
def register(body: RegisterRequest):
|
def register(body: RegisterRequest):
|
||||||
"""
|
"""
|
||||||
Registro por email o teléfono.
|
Registro por email o teléfono. Usa el admin client para confirmar automáticamente
|
||||||
- Email: flujo estándar Supabase email+password.
|
sin requerir que el usuario verifique su correo.
|
||||||
- Teléfono: requiere que Supabase tenga configurado un proveedor SMS (Twilio).
|
|
||||||
"""
|
"""
|
||||||
if not body.email and not body.phone:
|
if not body.email and not body.phone:
|
||||||
raise HTTPException(status_code=400, detail="Se requiere email o teléfono")
|
raise HTTPException(status_code=400, detail="Se requiere email o teléfono")
|
||||||
@@ -29,47 +51,83 @@ def register(body: RegisterRequest):
|
|||||||
if len(body.password) < 6:
|
if len(body.password) < 6:
|
||||||
raise HTTPException(status_code=400, detail="La contraseña debe tener al menos 6 caracteres.")
|
raise HTTPException(status_code=400, detail="La contraseña debe tener al menos 6 caracteres.")
|
||||||
|
|
||||||
|
# Crear usuario con confirmación automática vía service_role (bypasea email confirmation)
|
||||||
try:
|
try:
|
||||||
|
create_attrs: dict = {"password": body.password}
|
||||||
if body.email:
|
if body.email:
|
||||||
resp = supabase.auth.sign_up({"email": body.email, "password": body.password})
|
create_attrs["email"] = body.email
|
||||||
|
create_attrs["email_confirm"] = True
|
||||||
else:
|
else:
|
||||||
resp = supabase.auth.sign_up({"phone": body.phone, "password": body.password})
|
create_attrs["phone"] = body.phone
|
||||||
|
create_attrs["phone_confirm"] = True
|
||||||
|
|
||||||
|
admin_resp = supabase_admin.auth.admin.create_user(create_attrs)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
if "already registered" in error_msg.lower() or "user already exists" in error_msg.lower():
|
if "already registered" in error_msg.lower() or "user already exists" in error_msg.lower() or "already been registered" in error_msg.lower():
|
||||||
raise HTTPException(status_code=400, detail="El usuario ya está registrado en el sistema de autenticación.")
|
raise HTTPException(status_code=400, detail="El usuario ya está registrado.")
|
||||||
if "signups are disabled" in error_msg.lower():
|
if "signups are disabled" in error_msg.lower():
|
||||||
raise HTTPException(status_code=400, detail="El registro de nuevos usuarios está deshabilitado temporalmente.")
|
raise HTTPException(status_code=400, detail="El registro de nuevos usuarios está deshabilitado temporalmente.")
|
||||||
if "rate limit" in error_msg.lower():
|
|
||||||
raise HTTPException(status_code=400, detail="Límite de registros excedido por seguridad. Desactiva la confirmación de correos en Supabase o intenta más tarde.")
|
|
||||||
raise HTTPException(status_code=400, detail=error_msg)
|
raise HTTPException(status_code=400, detail=error_msg)
|
||||||
|
|
||||||
auth_user = resp.user
|
auth_user = admin_resp.user
|
||||||
if not auth_user:
|
if not auth_user:
|
||||||
raise HTTPException(status_code=400, detail="No se pudo crear el usuario en Supabase Auth")
|
raise HTTPException(status_code=400, detail="No se pudo crear el usuario en Supabase Auth")
|
||||||
|
|
||||||
# Crear entrada en public.users con el rol elegido
|
# Crear entrada en public.users con el rol elegido
|
||||||
try:
|
try:
|
||||||
supabase_admin.table("users").upsert(
|
supabase_admin.table("users").upsert(
|
||||||
{
|
{"id": str(auth_user.id), "role": body.role}
|
||||||
"id": str(auth_user.id),
|
|
||||||
"role": body.role,
|
|
||||||
}
|
|
||||||
).execute()
|
).execute()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Error al guardar el usuario: {e}")
|
raise HTTPException(status_code=500, detail=f"Error al guardar el usuario: {e}")
|
||||||
|
|
||||||
# Si no hubo sesión (email confirmation pendiente)
|
# Iniciar sesión para obtener el JWT
|
||||||
if not resp.session:
|
try:
|
||||||
raise HTTPException(
|
if body.email:
|
||||||
status_code=400,
|
session_resp = supabase.auth.sign_in_with_password(
|
||||||
detail="Cuenta creada. Revisa tu correo para confirmar tu email antes de iniciar sesión.",
|
{"email": body.email, "password": body.password}
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
session_resp = supabase.auth.sign_in_with_password(
|
||||||
|
{"phone": body.phone, "password": body.password}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Usuario creado pero no se pudo iniciar sesión: {e}")
|
||||||
|
|
||||||
|
# Guardar dirección inicial si viene en el payload (evita un segundo HTTP call desde Flutter)
|
||||||
|
saved_route_id: str | None = None
|
||||||
|
if body.address_calle and body.address_colonia:
|
||||||
|
try:
|
||||||
|
from app.services.simulation import get_colonias
|
||||||
|
mapping = get_colonias()
|
||||||
|
match = next(
|
||||||
|
(c for c in mapping if c.get("colonia", "").lower() == body.address_colonia.lower()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if match:
|
||||||
|
addr_data: dict = {
|
||||||
|
"user_id": str(auth_user.id),
|
||||||
|
"label": body.address_label or "Mi Casa",
|
||||||
|
"calle": body.address_calle,
|
||||||
|
"colonia": body.address_colonia,
|
||||||
|
"route_id": match["routeId"],
|
||||||
|
"verified": False,
|
||||||
|
}
|
||||||
|
if body.address_lat is not None:
|
||||||
|
addr_data["lat"] = body.address_lat
|
||||||
|
if body.address_lng is not None:
|
||||||
|
addr_data["lng"] = body.address_lng
|
||||||
|
supabase_admin.table("addresses").insert(addr_data).execute()
|
||||||
|
saved_route_id = match["routeId"]
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[register] No se pudo guardar la dirección inicial: {e}")
|
||||||
|
|
||||||
return TokenResponse(
|
return TokenResponse(
|
||||||
access_token=resp.session.access_token,
|
access_token=session_resp.session.access_token,
|
||||||
user_id=str(auth_user.id),
|
user_id=str(auth_user.id),
|
||||||
role=body.role,
|
role=body.role,
|
||||||
|
route_id=saved_route_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -92,10 +150,16 @@ def login(body: LoginRequest):
|
|||||||
raise HTTPException(status_code=401, detail="Credenciales inválidas")
|
raise HTTPException(status_code=401, detail="Credenciales inválidas")
|
||||||
|
|
||||||
auth_user = resp.user
|
auth_user = resp.user
|
||||||
role = _fetch_role(str(auth_user.id))
|
user_id = str(auth_user.id)
|
||||||
|
role = _fetch_role(user_id)
|
||||||
|
|
||||||
|
route_id = None
|
||||||
|
if role == 'citizen':
|
||||||
|
route_id = _fetch_route_for_citizen(user_id)
|
||||||
|
|
||||||
return TokenResponse(
|
return TokenResponse(
|
||||||
access_token=resp.session.access_token,
|
access_token=resp.session.access_token,
|
||||||
user_id=str(auth_user.id),
|
user_id=user_id,
|
||||||
role=role,
|
role=role,
|
||||||
|
route_id=route_id,
|
||||||
)
|
)
|
||||||
|
|||||||
43
backend/app/api/users.py
Normal file
43
backend/app/api/users.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from app.core.deps import get_current_user
|
||||||
|
from app.core.supabase_client import supabase_admin
|
||||||
|
from gotrue.types import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
email: EmailStr | None = None
|
||||||
|
|
||||||
|
@router.patch("/me", status_code=204)
|
||||||
|
def update_user_profile(
|
||||||
|
update_data: UserUpdate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Actualiza el perfil del usuario autenticado (nombre, email).
|
||||||
|
"""
|
||||||
|
user_id = current_user.id
|
||||||
|
update_payload = {}
|
||||||
|
|
||||||
|
if update_data.name:
|
||||||
|
# El nombre no está en Supabase Auth, sino en nuestra tabla `users`
|
||||||
|
try:
|
||||||
|
supabase_admin.table("users").update({"name": update_data.name}).eq(
|
||||||
|
"id", user_id
|
||||||
|
).execute()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error al actualizar el nombre: {e}")
|
||||||
|
|
||||||
|
if update_data.email:
|
||||||
|
# El email sí está en Supabase Auth
|
||||||
|
try:
|
||||||
|
supabase_admin.auth.admin.update_user_by_id(
|
||||||
|
user_id, {"email": update_data.email}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error al actualizar el email: {e}")
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
@@ -6,6 +6,8 @@ class AddressCreate(BaseModel):
|
|||||||
label: str
|
label: str
|
||||||
calle: str
|
calle: str
|
||||||
colonia: str # el backend deriva route_id a partir de colonias-rutas.json
|
colonia: str # el backend deriva route_id a partir de colonias-rutas.json
|
||||||
|
lat: Optional[float] = None
|
||||||
|
lng: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
class AddressResponse(BaseModel):
|
class AddressResponse(BaseModel):
|
||||||
@@ -19,3 +21,5 @@ class AddressResponse(BaseModel):
|
|||||||
verified_method: Optional[str] = None
|
verified_method: Optional[str] = None
|
||||||
verified_at: Optional[str] = None
|
verified_at: Optional[str] = None
|
||||||
created_at: Optional[str] = None
|
created_at: Optional[str] = None
|
||||||
|
lat: Optional[float] = None
|
||||||
|
lng: Optional[float] = None
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ class RegisterRequest(BaseModel):
|
|||||||
phone: Optional[str] = None
|
phone: Optional[str] = None
|
||||||
password: str
|
password: str
|
||||||
role: Literal["citizen", "driver", "admin"] = "citizen"
|
role: Literal["citizen", "driver", "admin"] = "citizen"
|
||||||
|
# Dirección inicial (opcional, se guarda en el mismo request para evitar un segundo HTTP call)
|
||||||
|
address_label: Optional[str] = None
|
||||||
|
address_calle: Optional[str] = None
|
||||||
|
address_colonia: Optional[str] = None
|
||||||
|
address_lat: Optional[float] = None
|
||||||
|
address_lng: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
@@ -20,3 +26,6 @@ class TokenResponse(BaseModel):
|
|||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
user_id: str
|
user_id: str
|
||||||
role: str
|
role: str
|
||||||
|
# route_id se incluye opcionalmente para ciudadanos; permite al cliente
|
||||||
|
# suscribirse al topic correcto inmediatamente después del login.
|
||||||
|
route_id: Optional[str] = None
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from app.api.eta import router as eta_router
|
|||||||
from app.api.auth import router as auth_router
|
from app.api.auth import router as auth_router
|
||||||
from app.api.addresses import router as addresses_router
|
from app.api.addresses import router as addresses_router
|
||||||
from app.api.colonias import router as colonias_router
|
from app.api.colonias import router as colonias_router
|
||||||
from app.api.simulation import router as simulation_router
|
from app.api.users import router as users_router
|
||||||
from app.services import simulation, notifications
|
from app.services import simulation, notifications
|
||||||
|
|
||||||
scheduler = AsyncIOScheduler()
|
scheduler = AsyncIOScheduler()
|
||||||
@@ -48,8 +48,8 @@ app = FastAPI(
|
|||||||
# CORS — necesario para el simulador web y el cliente Flutter
|
# CORS — necesario para el simulador web y el cliente Flutter
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"], # En producción limitar a dominios reales
|
allow_origins=["*"],
|
||||||
allow_credentials=True,
|
allow_credentials=False, # Usamos Bearer tokens, no cookies — wildcard compatible
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
@@ -58,17 +58,4 @@ app.include_router(auth_router)
|
|||||||
app.include_router(addresses_router)
|
app.include_router(addresses_router)
|
||||||
app.include_router(eta_router)
|
app.include_router(eta_router)
|
||||||
app.include_router(colonias_router)
|
app.include_router(colonias_router)
|
||||||
app.include_router(simulation_router)
|
app.include_router(users_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
|
||||||
def read_root():
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"message": "Backend operativo. Regla innegociable #1: NUNCA se devuelven coordenadas al ciudadano.",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
def health_check():
|
|
||||||
return {"status": "healthy"}
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# This file should be version controlled and should not be manually edited.
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
version:
|
version:
|
||||||
revision: "db50e20168db8fee486b9abf32fc912de3bc5b6a"
|
revision: "559ffa3f75e7402d65a8def9c28389a9b2e6fe42"
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
|
|
||||||
project_type: app
|
project_type: app
|
||||||
@@ -13,26 +13,26 @@ project_type: app
|
|||||||
migration:
|
migration:
|
||||||
platforms:
|
platforms:
|
||||||
- platform: root
|
- platform: root
|
||||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
- platform: android
|
- platform: android
|
||||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
- platform: ios
|
- platform: ios
|
||||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
- platform: linux
|
- platform: linux
|
||||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
- platform: macos
|
- platform: macos
|
||||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
- platform: web
|
- platform: web
|
||||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
- platform: windows
|
- platform: windows
|
||||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
|
|
||||||
# User provided section
|
# User provided section
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"flutter":{"platforms":{"android":{"default":{"projectId":"recoleccion-app","appId":"1:446089041715:android:561dccabff253d1f879046","fileOutput":"android/app/google-services.json"}},"dart":{"lib/firebase_options.dart":{"projectId":"recoleccion-app","configurations":{"android":"1:446089041715:android:561dccabff253d1f879046","ios":"1:446089041715:ios:6edb76038f517454879046","macos":"1:446089041715:ios:6edb76038f517454879046","web":"1:446089041715:web:4675e76c702e083e879046","windows":"1:446089041715:web:d0f612e0a6749eea879046"}}}}}}
|
{"flutter":{"platforms":{"android":{"default":{"projectId":"recoleccion-app","appId":"1:446089041715:android:561dccabff253d1f879046","fileOutput":"android/app/google-services.json"}},"dart":{"lib/firebase_options.dart":{"projectId":"recoleccion-app","configurations":{"android":"1:446089041715:android:561dccabff253d1f879046","ios":"1:446089041715:ios:6edb76038f517454879046","macos":"1:446089041715:ios:6edb76038f517454879046","web":"1:446089041715:web:4675e76c702e083e879046","windows":"1:446089041715:web:d0f612e0a6749eea879046"}}}}}}vsls:/
|
||||||
40
recolecta_app/lib/core/api/api_service.dart
Normal file
40
recolecta_app/lib/core/api/api_service.dart
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:recolecta_app/core/storage/secure_storage.dart';
|
||||||
|
|
||||||
|
final apiServiceProvider = Provider<ApiService>((ref) {
|
||||||
|
return ApiService(ref);
|
||||||
|
});
|
||||||
|
|
||||||
|
class ApiService {
|
||||||
|
final Ref _ref;
|
||||||
|
final Dio _dio = Dio(
|
||||||
|
BaseOptions(
|
||||||
|
baseUrl: 'http://localhost:8000', // O la URL de tu backend
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
ApiService(this._ref) {
|
||||||
|
_dio.interceptors.add(
|
||||||
|
InterceptorsWrapper(
|
||||||
|
onRequest: (options, handler) async {
|
||||||
|
final token = await _ref
|
||||||
|
.read(secureStorageProvider)
|
||||||
|
.read(key: 'auth_token');
|
||||||
|
if (token != null) {
|
||||||
|
options.headers['Authorization'] = 'Bearer $token';
|
||||||
|
}
|
||||||
|
return handler.next(options);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateUser(Map<String, dynamic> data) async {
|
||||||
|
try {
|
||||||
|
await _dio.patch('/users/me', data: data);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw e.response?.data['detail'] ?? 'Error de red';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,8 @@ class UIHouseModel {
|
|||||||
final String calle;
|
final String calle;
|
||||||
final String colonia;
|
final String colonia;
|
||||||
final String? routeId;
|
final String? routeId;
|
||||||
|
final double? lat;
|
||||||
|
final double? lng;
|
||||||
final int radioAlertaMetros;
|
final int radioAlertaMetros;
|
||||||
final bool alertaCercana;
|
final bool alertaCercana;
|
||||||
final bool alertaMedia;
|
final bool alertaMedia;
|
||||||
@@ -45,6 +47,8 @@ class UIHouseModel {
|
|||||||
required this.calle,
|
required this.calle,
|
||||||
required this.colonia,
|
required this.colonia,
|
||||||
this.routeId,
|
this.routeId,
|
||||||
|
this.lat,
|
||||||
|
this.lng,
|
||||||
this.radioAlertaMetros = 200,
|
this.radioAlertaMetros = 200,
|
||||||
this.alertaCercana = true,
|
this.alertaCercana = true,
|
||||||
this.alertaMedia = false,
|
this.alertaMedia = false,
|
||||||
@@ -59,6 +63,8 @@ class UIHouseModel {
|
|||||||
String? calle,
|
String? calle,
|
||||||
String? colonia,
|
String? colonia,
|
||||||
String? routeId,
|
String? routeId,
|
||||||
|
double? lat,
|
||||||
|
double? lng,
|
||||||
int? radioAlertaMetros,
|
int? radioAlertaMetros,
|
||||||
bool? alertaCercana,
|
bool? alertaCercana,
|
||||||
bool? alertaMedia,
|
bool? alertaMedia,
|
||||||
@@ -71,6 +77,8 @@ class UIHouseModel {
|
|||||||
calle: calle ?? this.calle,
|
calle: calle ?? this.calle,
|
||||||
colonia: colonia ?? this.colonia,
|
colonia: colonia ?? this.colonia,
|
||||||
routeId: routeId ?? this.routeId,
|
routeId: routeId ?? this.routeId,
|
||||||
|
lat: lat ?? this.lat,
|
||||||
|
lng: lng ?? this.lng,
|
||||||
radioAlertaMetros: radioAlertaMetros ?? this.radioAlertaMetros,
|
radioAlertaMetros: radioAlertaMetros ?? this.radioAlertaMetros,
|
||||||
alertaCercana: alertaCercana ?? this.alertaCercana,
|
alertaCercana: alertaCercana ?? this.alertaCercana,
|
||||||
alertaMedia: alertaMedia ?? this.alertaMedia,
|
alertaMedia: alertaMedia ?? this.alertaMedia,
|
||||||
@@ -86,6 +94,8 @@ class UIHouseModel {
|
|||||||
calle: json['calle'] as String? ?? '',
|
calle: json['calle'] as String? ?? '',
|
||||||
colonia: json['colonia'] as String? ?? '',
|
colonia: json['colonia'] as String? ?? '',
|
||||||
routeId: json['route_id'] as String?,
|
routeId: json['route_id'] as String?,
|
||||||
|
lat: (json['lat'] as num?)?.toDouble(),
|
||||||
|
lng: (json['lng'] as num?)?.toDouble(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ import 'package:recolecta_app/features/home/house_screen.dart';
|
|||||||
import 'package:recolecta_app/features/alerts/alerts_screen.dart';
|
import 'package:recolecta_app/features/alerts/alerts_screen.dart';
|
||||||
import 'package:recolecta_app/features/profile/profile_screen.dart';
|
import 'package:recolecta_app/features/profile/profile_screen.dart';
|
||||||
import 'package:recolecta_app/features/feedback/feedback_screen.dart';
|
import 'package:recolecta_app/features/feedback/feedback_screen.dart';
|
||||||
|
import 'package:recolecta_app/features/profile/edit_profile_screen.dart';
|
||||||
import 'package:recolecta_app/features/separation_guide/screens/category_detail_screen.dart';
|
import 'package:recolecta_app/features/separation_guide/screens/category_detail_screen.dart';
|
||||||
import 'package:recolecta_app/features/separation_guide/screens/separation_guide_screen.dart';
|
import 'package:recolecta_app/features/separation_guide/screens/separation_guide_screen.dart';
|
||||||
import 'package:recolecta_app/core/services/auth_controller.dart';
|
import 'package:recolecta_app/core/services/auth_controller.dart';
|
||||||
|
import '../../features/addresses/add_address_page.dart';
|
||||||
import '../../features/admin/screens/admin_dashboard_screen.dart';
|
import '../../features/admin/screens/admin_dashboard_screen.dart';
|
||||||
import '../../features/notifications/notifications_screen.dart';
|
import '../../features/notifications/notifications_screen.dart';
|
||||||
import '../../features/quiz/quiz_screen.dart';
|
import '../../features/quiz/quiz_screen.dart';
|
||||||
@@ -75,6 +77,10 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
path: '/register',
|
path: '/register',
|
||||||
builder: (context, state) => const RegisterPage(),
|
builder: (context, state) => const RegisterPage(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/edit-profile',
|
||||||
|
builder: (context, state) => const EditProfileScreen(),
|
||||||
|
),
|
||||||
|
|
||||||
// ── Admin ─────────────────────────────────────────────────────────────
|
// ── Admin ─────────────────────────────────────────────────────────────
|
||||||
ShellRoute(
|
ShellRoute(
|
||||||
@@ -145,6 +151,10 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
path: '/feedback',
|
path: '/feedback',
|
||||||
builder: (context, state) => const FeedbackScreen(),
|
builder: (context, state) => const FeedbackScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/add-address',
|
||||||
|
builder: (context, state) => const AddAddressPage(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/guide',
|
path: '/guide',
|
||||||
builder: (context, state) => const SeparationGuideScreen(),
|
builder: (context, state) => const SeparationGuideScreen(),
|
||||||
@@ -165,10 +175,7 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
path: '/notifications',
|
path: '/notifications',
|
||||||
builder: (context, state) => const NotificationsScreen(),
|
builder: (context, state) => const NotificationsScreen(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(path: '/quiz', builder: (context, state) => const QuizScreen()),
|
||||||
path: '/quiz',
|
|
||||||
builder: (context, state) => const QuizScreen(),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,12 +47,25 @@ class AuthController extends AsyncNotifier<AuthState> {
|
|||||||
required String email,
|
required String email,
|
||||||
required String phone,
|
required String phone,
|
||||||
required String password,
|
required String password,
|
||||||
|
String? addressCalle,
|
||||||
|
String? addressColonia,
|
||||||
|
String? addressLabel,
|
||||||
|
double? addressLat,
|
||||||
|
double? addressLng,
|
||||||
}) async {
|
}) async {
|
||||||
state = const AsyncLoading<AuthState>();
|
state = const AsyncLoading<AuthState>();
|
||||||
try {
|
try {
|
||||||
final session = await ref
|
final session = await ref.read(authServiceProvider).register(
|
||||||
.read(authServiceProvider)
|
email: email,
|
||||||
.register(email: email, phone: phone, password: password);
|
phone: phone,
|
||||||
|
password: password,
|
||||||
|
addressCalle: addressCalle,
|
||||||
|
addressColonia: addressColonia,
|
||||||
|
addressLabel: addressLabel,
|
||||||
|
addressLat: addressLat,
|
||||||
|
addressLng: addressLng,
|
||||||
|
);
|
||||||
|
|
||||||
final authState = AuthState.authenticated(
|
final authState = AuthState.authenticated(
|
||||||
token: session.token,
|
token: session.token,
|
||||||
userRole: session.userRole,
|
userRole: session.userRole,
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ class AuthService {
|
|||||||
required String email,
|
required String email,
|
||||||
required String phone,
|
required String phone,
|
||||||
required String password,
|
required String password,
|
||||||
|
String? addressCalle,
|
||||||
|
String? addressColonia,
|
||||||
|
String? addressLabel,
|
||||||
|
double? addressLat,
|
||||||
|
double? addressLng,
|
||||||
}) {
|
}) {
|
||||||
return _authenticate(
|
return _authenticate(
|
||||||
path: '/auth/register',
|
path: '/auth/register',
|
||||||
@@ -48,6 +53,11 @@ class AuthService {
|
|||||||
'email': email,
|
'email': email,
|
||||||
'phone': phone,
|
'phone': phone,
|
||||||
'password': password,
|
'password': password,
|
||||||
|
if (addressCalle != null) 'address_calle': addressCalle,
|
||||||
|
if (addressColonia != null) 'address_colonia': addressColonia,
|
||||||
|
if (addressLabel != null) 'address_label': addressLabel,
|
||||||
|
if (addressLat != null) 'address_lat': addressLat,
|
||||||
|
if (addressLng != null) 'address_lng': addressLng,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
422
recolecta_app/lib/features/addresses/add_address_page.dart
Normal file
422
recolecta_app/lib/features/addresses/add_address_page.dart
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
import '../../core/constants/auth_constants.dart';
|
||||||
|
import '../../core/models/colonia.dart';
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../core/widgets/app_widgets.dart';
|
||||||
|
import '../home/colonias_data.dart';
|
||||||
|
import 'colonias_provider.dart';
|
||||||
|
|
||||||
|
const Map<String, String> _cpToColonia = {
|
||||||
|
'38000': 'Zona Centro',
|
||||||
|
'38060': 'Las Arboledas',
|
||||||
|
'38027': 'San Juanico',
|
||||||
|
'38037': 'Los Olivos',
|
||||||
|
'38090': 'Rancho Seco',
|
||||||
|
'38080': 'Las Insurgentes',
|
||||||
|
'38086': 'Trojes',
|
||||||
|
};
|
||||||
|
|
||||||
|
class AddAddressPage extends ConsumerStatefulWidget {
|
||||||
|
const AddAddressPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AddAddressPage> createState() => _AddAddressPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddAddressPageState extends ConsumerState<AddAddressPage> {
|
||||||
|
final _mapController = MapController();
|
||||||
|
final _cpCtrl = TextEditingController();
|
||||||
|
final _calleCtrl = TextEditingController();
|
||||||
|
final _labelCtrl = TextEditingController(text: 'Mi Casa');
|
||||||
|
|
||||||
|
Colonia? _selectedColonia;
|
||||||
|
LatLng? _selectedLocation;
|
||||||
|
bool _loading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_mapController.dispose();
|
||||||
|
_cpCtrl.dispose();
|
||||||
|
_calleCtrl.dispose();
|
||||||
|
_labelCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchStreetName(LatLng latlng) async {
|
||||||
|
setState(() => _selectedLocation = latlng);
|
||||||
|
try {
|
||||||
|
final dio = Dio();
|
||||||
|
final response = await dio.get(
|
||||||
|
'https://nominatim.openstreetmap.org/reverse',
|
||||||
|
queryParameters: {
|
||||||
|
'lat': latlng.latitude,
|
||||||
|
'lon': latlng.longitude,
|
||||||
|
'format': 'json',
|
||||||
|
'addressdetails': 1,
|
||||||
|
},
|
||||||
|
options: kIsWeb
|
||||||
|
? null
|
||||||
|
: Options(headers: {'User-Agent': 'com.onlineshack.recolecta'}),
|
||||||
|
);
|
||||||
|
if (response.data?['address'] != null) {
|
||||||
|
final addr = response.data['address'] as Map;
|
||||||
|
final road = addr['road'] ?? addr['pedestrian'] ?? addr['street'] ?? '';
|
||||||
|
final num = addr['house_number'] ?? '';
|
||||||
|
if ((road as String).isNotEmpty) {
|
||||||
|
setState(() => _calleCtrl.text = '$road $num'.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Nominatim error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _validarCP(String cp, List<Colonia> colonias) {
|
||||||
|
if (cp.length != 5) {
|
||||||
|
if (_selectedColonia != null) {
|
||||||
|
setState(() {
|
||||||
|
_selectedColonia = null;
|
||||||
|
_selectedLocation = null;
|
||||||
|
_calleCtrl.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final nombre = _cpToColonia[cp];
|
||||||
|
if (nombre == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Código postal fuera de nuestra zona de servicio.'),
|
||||||
|
backgroundColor: AppTheme.danger,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
_selectedColonia = null;
|
||||||
|
_selectedLocation = null;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final backendC = colonias
|
||||||
|
.where((c) => c.nombre.toLowerCase() == nombre.toLowerCase())
|
||||||
|
.firstOrNull;
|
||||||
|
if (backendC == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Esta colonia aún no tiene horarios configurados.'),
|
||||||
|
backgroundColor: AppTheme.danger,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
_selectedColonia = null;
|
||||||
|
_selectedLocation = null;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_selectedColonia = backendC;
|
||||||
|
_selectedLocation = kColoniasCoordinates[nombre];
|
||||||
|
});
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _guardar() async {
|
||||||
|
if (_calleCtrl.text.trim().isEmpty || _selectedColonia == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Ingresa tu calle y selecciona una colonia'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _loading = true);
|
||||||
|
try {
|
||||||
|
const storage = FlutterSecureStorage();
|
||||||
|
final token = await storage.read(key: authTokenStorageKey) ?? '';
|
||||||
|
|
||||||
|
final dio = Dio(
|
||||||
|
BaseOptions(
|
||||||
|
baseUrl: const String.fromEnvironment(
|
||||||
|
'API_BASE_URL',
|
||||||
|
defaultValue: 'http://localhost:8000',
|
||||||
|
),
|
||||||
|
headers: {'Authorization': 'Bearer $token'},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
'label': _labelCtrl.text.trim().isEmpty
|
||||||
|
? 'Mi Casa'
|
||||||
|
: _labelCtrl.text.trim(),
|
||||||
|
'calle': _calleCtrl.text.trim(),
|
||||||
|
'colonia': _selectedColonia!.nombre,
|
||||||
|
};
|
||||||
|
if (_selectedLocation != null) {
|
||||||
|
body['lat'] = _selectedLocation!.latitude;
|
||||||
|
body['lng'] = _selectedLocation!.longitude;
|
||||||
|
}
|
||||||
|
await dio.post('/addresses', data: body);
|
||||||
|
|
||||||
|
if (mounted) Navigator.pop(context, true);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
final msg = (e.response?.data is Map)
|
||||||
|
? e.response!.data['detail'] ?? 'Error al guardar'
|
||||||
|
: 'Error al guardar la dirección';
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(msg),
|
||||||
|
backgroundColor: AppTheme.danger,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Error: $e'),
|
||||||
|
backgroundColor: AppTheme.danger,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final coloniasList = ref.watch(coloniasProvider).value ?? [];
|
||||||
|
|
||||||
|
final baseCenter = _selectedColonia != null
|
||||||
|
? kColoniaCenter(_selectedColonia!.nombre)
|
||||||
|
: const LatLng(20.5222, -100.8123);
|
||||||
|
final mapCenter = _selectedLocation ?? baseCenter;
|
||||||
|
|
||||||
|
final bounds = _selectedColonia != null
|
||||||
|
? LatLngBounds(
|
||||||
|
LatLng(baseCenter.latitude - 0.01, baseCenter.longitude - 0.01),
|
||||||
|
LatLng(baseCenter.latitude + 0.01, baseCenter.longitude + 0.01),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppTheme.background,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
iconTheme: const IconThemeData(color: AppTheme.textPrimary),
|
||||||
|
title: const Text(
|
||||||
|
'Agregar dirección',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
AppFormCard(
|
||||||
|
icon: Icons.home_outlined,
|
||||||
|
title: 'Dirección de tu casa',
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
AppFormField(
|
||||||
|
label: 'Etiqueta',
|
||||||
|
hint: 'Ej. Mi Casa, Trabajo',
|
||||||
|
controller: _labelCtrl,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
AppFormField(
|
||||||
|
label: 'Código Postal',
|
||||||
|
hint: 'Ej. 38000',
|
||||||
|
controller: _cpCtrl,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
onChanged: (v) => _validarCP(v, coloniasList),
|
||||||
|
),
|
||||||
|
if (_selectedColonia != null) ...[
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryLight.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
|
||||||
|
border: Border.all(color: AppTheme.primaryMid),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.check_circle_outline,
|
||||||
|
color: AppTheme.primary,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Colonia: ${_selectedColonia!.nombre}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_selectedColonia!.horarioEstimado != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Horario ${_selectedColonia!.turno?.toLowerCase() ?? ''}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_selectedColonia!.horarioEstimado!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
AppFormField(
|
||||||
|
label: 'Calle y número',
|
||||||
|
hint: 'Av. Insurgentes 245',
|
||||||
|
controller: _calleCtrl,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'Toca el mapa para ubicar tu casa exacta:',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
height: 200,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
|
||||||
|
border: Border.all(color: AppTheme.border),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
child: FlutterMap(
|
||||||
|
mapController: _mapController,
|
||||||
|
options: MapOptions(
|
||||||
|
initialCenter: mapCenter,
|
||||||
|
initialZoom: 15.0,
|
||||||
|
cameraConstraint: bounds != null
|
||||||
|
? CameraConstraint.containCenter(bounds: bounds)
|
||||||
|
: const CameraConstraint.unconstrained(),
|
||||||
|
onTap: (_, latlng) => _fetchStreetName(latlng),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TileLayer(
|
||||||
|
urlTemplate:
|
||||||
|
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
userAgentPackageName: 'com.onlineshack.recolecta',
|
||||||
|
),
|
||||||
|
if (_selectedLocation != null)
|
||||||
|
MarkerLayer(
|
||||||
|
markers: [
|
||||||
|
Marker(
|
||||||
|
point: _selectedLocation!,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
child: const Icon(
|
||||||
|
Icons.location_on,
|
||||||
|
color: AppTheme.danger,
|
||||||
|
size: 40,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Center(
|
||||||
|
child: Text(
|
||||||
|
'Ingresa un código postal con servicio\npara asignar tu colonia.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 52,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _loading ? null : _guardar,
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
child: _loading
|
||||||
|
? const SizedBox(
|
||||||
|
key: ValueKey('loading'),
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Row(
|
||||||
|
key: ValueKey('text'),
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.check, size: 18),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
'Guardar dirección',
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
121
recolecta_app/lib/features/addresses/address_map_card.dart
Normal file
121
recolecta_app/lib/features/addresses/address_map_card.dart
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../home/colonias_data.dart';
|
||||||
|
|
||||||
|
class AddressMapCard extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String street;
|
||||||
|
final String colonia;
|
||||||
|
final double? lat;
|
||||||
|
final double? lng;
|
||||||
|
|
||||||
|
const AddressMapCard({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
required this.street,
|
||||||
|
required this.colonia,
|
||||||
|
this.lat,
|
||||||
|
this.lng,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Si existen coordenadas exactas las usa, de lo contrario cae al centro de la colonia
|
||||||
|
final center = (lat != null && lng != null)
|
||||||
|
? LatLng(lat!, lng!)
|
||||||
|
: kColoniaCenter(colonia);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
||||||
|
border: Border.all(color: AppTheme.border, width: 0.5),
|
||||||
|
boxShadow: AppTheme.softShadow,
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// ── Mapa no interactivo ──
|
||||||
|
SizedBox(
|
||||||
|
height: 130,
|
||||||
|
child: FlutterMap(
|
||||||
|
options: MapOptions(
|
||||||
|
initialCenter: center,
|
||||||
|
initialZoom: 16.0,
|
||||||
|
// ¡Esta línea desactiva todas las interacciones!
|
||||||
|
interactionOptions: const InteractionOptions(
|
||||||
|
flags: InteractiveFlag.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TileLayer(
|
||||||
|
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
userAgentPackageName: 'com.onlineshack.recolecta',
|
||||||
|
),
|
||||||
|
MarkerLayer(
|
||||||
|
markers: [
|
||||||
|
Marker(
|
||||||
|
point: center,
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
child: const Icon(
|
||||||
|
Icons.home_rounded,
|
||||||
|
color: AppTheme.primary,
|
||||||
|
size: 36,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Información en texto ──
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
label.toLowerCase().contains('negocio')
|
||||||
|
? Icons.storefront
|
||||||
|
: Icons.home_outlined,
|
||||||
|
color: AppTheme.primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(street, style: const TextStyle(fontSize: 14)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'Colonia $colonia',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,28 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
import '../../core/models/address_create_request.dart';
|
import '../../core/models/address_create_request.dart';
|
||||||
import '../../core/models/colonia.dart';
|
import '../../core/models/colonia.dart';
|
||||||
import 'colonias_selector.dart';
|
import '../home/colonias_data.dart';
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../core/widgets/app_widgets.dart';
|
||||||
|
import '../../core/constants/auth_constants.dart';
|
||||||
|
import 'colonias_provider.dart';
|
||||||
|
|
||||||
|
const Map<String, String> _cpToColonia = {
|
||||||
|
'38000': 'Zona Centro',
|
||||||
|
'38060': 'Las Arboledas',
|
||||||
|
'38027': 'San Juanico',
|
||||||
|
'38037': 'Los Olivos',
|
||||||
|
'38090': 'Rancho Seco',
|
||||||
|
'38080': 'Las Insurgentes',
|
||||||
|
'38086': 'Trojes',
|
||||||
|
};
|
||||||
|
|
||||||
class NewAddressPage extends ConsumerStatefulWidget {
|
class NewAddressPage extends ConsumerStatefulWidget {
|
||||||
const NewAddressPage({super.key});
|
const NewAddressPage({super.key});
|
||||||
@@ -15,40 +34,168 @@ class NewAddressPage extends ConsumerStatefulWidget {
|
|||||||
class _NewAddressPageState extends ConsumerState<NewAddressPage> {
|
class _NewAddressPageState extends ConsumerState<NewAddressPage> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final _labelController = TextEditingController();
|
final _labelController = TextEditingController();
|
||||||
|
final _cpCtrl = TextEditingController();
|
||||||
final _streetController = TextEditingController();
|
final _streetController = TextEditingController();
|
||||||
Colonia? _selectedColonia;
|
Colonia? _selectedColonia;
|
||||||
|
String _tipoInmueble = 'Casa';
|
||||||
|
|
||||||
|
final _mapController = MapController();
|
||||||
|
LatLng? _selectedLocation;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_labelController.dispose();
|
_labelController.dispose();
|
||||||
|
_cpCtrl.dispose();
|
||||||
_streetController.dispose();
|
_streetController.dispose();
|
||||||
|
_mapController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _saveAddress() {
|
Future<void> _fetchStreetName(LatLng latlng) async {
|
||||||
|
setState(() => _selectedLocation = latlng);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final dio = Dio();
|
||||||
|
final response = await dio.get(
|
||||||
|
'https://nominatim.openstreetmap.org/reverse',
|
||||||
|
queryParameters: {
|
||||||
|
'lat': latlng.latitude,
|
||||||
|
'lon': latlng.longitude,
|
||||||
|
'format': 'json',
|
||||||
|
'addressdetails': 1,
|
||||||
|
},
|
||||||
|
options: kIsWeb
|
||||||
|
? null
|
||||||
|
: Options(headers: {'User-Agent': 'com.onlineshack.recolecta'}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data != null && response.data['address'] != null) {
|
||||||
|
final address = response.data['address'];
|
||||||
|
final road =
|
||||||
|
address['road'] ?? address['pedestrian'] ?? address['street'] ?? '';
|
||||||
|
final houseNumber = address['house_number'] ?? '';
|
||||||
|
|
||||||
|
if (road.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_streetController.text = '$road $houseNumber'.trim();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Aviso: Error al obtener nombre de la calle de OSM: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _validarCP(String cp, List<Colonia> colonias) {
|
||||||
|
if (cp.length != 5) {
|
||||||
|
if (_selectedColonia != null) {
|
||||||
|
setState(() {
|
||||||
|
_selectedColonia = null;
|
||||||
|
_selectedLocation = null;
|
||||||
|
_streetController.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final nombre = _cpToColonia[cp];
|
||||||
|
if (nombre == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Código postal fuera de nuestra zona de servicio actual.',
|
||||||
|
),
|
||||||
|
backgroundColor: AppTheme.danger,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
_selectedColonia = null;
|
||||||
|
_selectedLocation = null;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final backendC = colonias
|
||||||
|
.where((c) => c.nombre.toLowerCase() == nombre.toLowerCase())
|
||||||
|
.firstOrNull;
|
||||||
|
if (backendC == null) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_selectedColonia = backendC;
|
||||||
|
_selectedLocation = kColoniasCoordinates[nombre];
|
||||||
|
});
|
||||||
|
FocusScope.of(context).unfocus(); // Cierra el teclado
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveAddress() async {
|
||||||
if (!(_formKey.currentState?.validate() ?? false)) {
|
if (!(_formKey.currentState?.validate() ?? false)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_selectedColonia == null) {
|
if (_selectedColonia == null) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
const SnackBar(content: Text('Ingresa un código postal válido')),
|
||||||
).showSnackBar(const SnackBar(content: Text('Selecciona una colonia')));
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final address = AddressCreateRequest(
|
try {
|
||||||
label: _labelController.text.trim(),
|
const storage = FlutterSecureStorage();
|
||||||
street: _streetController.text.trim(),
|
final token = await storage.read(key: authTokenStorageKey) ?? '';
|
||||||
colonia: _selectedColonia!.nombre,
|
|
||||||
);
|
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
if (token.isNotEmpty) {
|
||||||
SnackBar(content: Text('Domicilio listo: ${address.toJson()}')),
|
final dio = Dio(
|
||||||
);
|
BaseOptions(
|
||||||
|
baseUrl: const String.fromEnvironment(
|
||||||
|
'API_BASE_URL',
|
||||||
|
defaultValue: 'http://localhost:8000',
|
||||||
|
),
|
||||||
|
headers: {'Authorization': 'Bearer $token'},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await dio.post(
|
||||||
|
'/addresses',
|
||||||
|
data: {
|
||||||
|
'label': _labelController.text.trim(),
|
||||||
|
'calle': _streetController.text.trim(),
|
||||||
|
'colonia': _selectedColonia!.nombre,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Domicilio agregado exitosamente')),
|
||||||
|
);
|
||||||
|
Navigator.pop(
|
||||||
|
context,
|
||||||
|
true,
|
||||||
|
); // Devuelve true para recargar la lista en la pantalla anterior
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error al guardar domicilio: $e');
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Error al guardar el domicilio'),
|
||||||
|
backgroundColor: AppTheme.danger,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final baseCenter = _selectedColonia != null
|
||||||
|
? kColoniasCoordinates[_selectedColonia!.nombre] ??
|
||||||
|
const LatLng(20.5222, -100.8123)
|
||||||
|
: const LatLng(20.5222, -100.8123);
|
||||||
|
|
||||||
|
final mapCenter = _selectedLocation ?? baseCenter;
|
||||||
|
|
||||||
|
final coloniasAsync = ref.watch(coloniasProvider);
|
||||||
|
final coloniasList = coloniasAsync.value ?? [];
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Nuevo domicilio')),
|
appBar: AppBar(title: const Text('Nuevo domicilio')),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
@@ -60,37 +207,214 @@ class _NewAddressPageState extends ConsumerState<NewAddressPage> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
TextFormField(
|
AppFormField(
|
||||||
|
label: 'Etiqueta',
|
||||||
|
hint: 'Ej. Casa de mis padres, Oficina...',
|
||||||
controller: _labelController,
|
controller: _labelController,
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Etiqueta',
|
|
||||||
hintText: 'Casa, trabajo, etc.',
|
|
||||||
),
|
|
||||||
validator: (value) =>
|
validator: (value) =>
|
||||||
(value == null || value.trim().isEmpty)
|
(value == null || value.trim().isEmpty)
|
||||||
? 'Ingresa una etiqueta'
|
? 'Ingresa una etiqueta'
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
const Text(
|
||||||
controller: _streetController,
|
'Selección de domicilio',
|
||||||
decoration: const InputDecoration(
|
style: TextStyle(
|
||||||
labelText: 'Calle',
|
fontSize: 12,
|
||||||
hintText: 'Av. Principal 123',
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
),
|
),
|
||||||
validator: (value) =>
|
),
|
||||||
(value == null || value.trim().isEmpty)
|
Row(
|
||||||
? 'Ingresa la calle'
|
children: [
|
||||||
: null,
|
Expanded(
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: RadioListTile<String>(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
),
|
||||||
|
visualDensity: const VisualDensity(
|
||||||
|
horizontal: -4,
|
||||||
|
vertical: -4,
|
||||||
|
),
|
||||||
|
title: const Text(
|
||||||
|
'Casa',
|
||||||
|
style: TextStyle(fontSize: 14),
|
||||||
|
),
|
||||||
|
value: 'Casa',
|
||||||
|
groupValue: _tipoInmueble,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() => _tipoInmueble = v!);
|
||||||
|
if (_labelController.text.trim().isEmpty ||
|
||||||
|
_labelController.text == 'Mi Negocio') {
|
||||||
|
_labelController.text = 'Mi Casa';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: RadioListTile<String>(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
),
|
||||||
|
visualDensity: const VisualDensity(
|
||||||
|
horizontal: -4,
|
||||||
|
vertical: -4,
|
||||||
|
),
|
||||||
|
title: const Text(
|
||||||
|
'Negocio',
|
||||||
|
style: TextStyle(fontSize: 14),
|
||||||
|
),
|
||||||
|
value: 'Negocio',
|
||||||
|
groupValue: _tipoInmueble,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() => _tipoInmueble = v!);
|
||||||
|
if (_labelController.text.trim().isEmpty ||
|
||||||
|
_labelController.text == 'Mi Casa') {
|
||||||
|
_labelController.text = 'Mi Negocio';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ColoniasSelector(
|
AppFormField(
|
||||||
labelText: 'Colonia',
|
label: 'Código Postal',
|
||||||
initialValue: _selectedColonia,
|
hint: 'Ej. 38000',
|
||||||
onChanged: (colonia) {
|
controller: _cpCtrl,
|
||||||
setState(() => _selectedColonia = colonia);
|
keyboardType: TextInputType.number,
|
||||||
},
|
onChanged: (v) => _validarCP(v, coloniasList),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
if (_selectedColonia != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryLight.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
|
||||||
|
border: Border.all(color: AppTheme.primaryMid),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.check_circle_outline,
|
||||||
|
color: AppTheme.primary,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Colonia: ${_selectedColonia!.nombre}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Horario ${_selectedColonia!.turno?.toLowerCase() ?? 'asignado'}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_selectedColonia!.horarioEstimado ??
|
||||||
|
'Sin horario especificado',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
AppFormField(
|
||||||
|
label: 'Calle y número',
|
||||||
|
hint: 'Av. Insurgentes 245',
|
||||||
|
controller: _streetController,
|
||||||
|
validator: (value) =>
|
||||||
|
(value == null || value.trim().isEmpty)
|
||||||
|
? 'Ingresa la calle'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'Toca el mapa para ubicar tu domicilio exacto:',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
height: 200,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
|
||||||
|
border: Border.all(color: AppTheme.border),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
child: FlutterMap(
|
||||||
|
mapController: _mapController,
|
||||||
|
options: MapOptions(
|
||||||
|
initialCenter: mapCenter,
|
||||||
|
initialZoom: 15.0,
|
||||||
|
onTap: (_, latlng) => _fetchStreetName(latlng),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TileLayer(
|
||||||
|
urlTemplate:
|
||||||
|
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
userAgentPackageName: 'com.onlineshack.recolecta',
|
||||||
|
),
|
||||||
|
if (_selectedLocation != null)
|
||||||
|
MarkerLayer(
|
||||||
|
markers: [
|
||||||
|
Marker(
|
||||||
|
point: _selectedLocation!,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
child: const Icon(
|
||||||
|
Icons.location_on,
|
||||||
|
color: AppTheme.danger,
|
||||||
|
size: 40,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Center(
|
||||||
|
child: Text(
|
||||||
|
'Ingresa un código postal con servicio\npara asignar tu colonia.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 52,
|
height: 52,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
@@ -11,6 +11,8 @@ import '../../core/theme/app_theme.dart';
|
|||||||
import '../../core/widgets/app_widgets.dart';
|
import '../../core/widgets/app_widgets.dart';
|
||||||
import '../../core/services/auth_controller.dart';
|
import '../../core/services/auth_controller.dart';
|
||||||
import '../../core/models/auth_state.dart';
|
import '../../core/models/auth_state.dart';
|
||||||
|
import '../../core/constants/auth_constants.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import '../../core/models/colonia.dart';
|
import '../../core/models/colonia.dart';
|
||||||
import '../home/colonias_data.dart';
|
import '../home/colonias_data.dart';
|
||||||
import '../addresses/colonias_provider.dart';
|
import '../addresses/colonias_provider.dart';
|
||||||
@@ -199,34 +201,6 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
FocusScope.of(context).unfocus(); // Cierra el teclado
|
FocusScope.of(context).unfocus(); // Cierra el teclado
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _postAddressInBackground(String calle, String colonia) async {
|
|
||||||
try {
|
|
||||||
const storage = FlutterSecureStorage();
|
|
||||||
// Esperar un momento para asegurar que el token se haya guardado
|
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
|
||||||
final token = await storage.read(key: 'token') ?? '';
|
|
||||||
|
|
||||||
if (token.isNotEmpty) {
|
|
||||||
final dio = Dio(
|
|
||||||
BaseOptions(
|
|
||||||
baseUrl: const String.fromEnvironment(
|
|
||||||
'API_BASE_URL',
|
|
||||||
defaultValue: 'http://localhost:8000',
|
|
||||||
),
|
|
||||||
headers: {'Authorization': 'Bearer $token'},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await dio.post(
|
|
||||||
'/addresses',
|
|
||||||
data: {'label': 'Mi Casa', 'calle': calle, 'colonia': colonia},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Aviso: No se pudo guardar la dirección inicial: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _register() async {
|
Future<void> _register() async {
|
||||||
if (_calleCtrl.text.trim().isEmpty || _selectedColonia == null) {
|
if (_calleCtrl.text.trim().isEmpty || _selectedColonia == null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -238,32 +212,65 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capturar variables antes del proceso asíncrono
|
final phoneDigits = _telefonoCtrl.text.replaceAll(RegExp(r'\D'), '');
|
||||||
|
final phone = phoneDigits.isNotEmpty ? '+52$phoneDigits' : '';
|
||||||
|
|
||||||
final calle = _calleCtrl.text.trim();
|
final calle = _calleCtrl.text.trim();
|
||||||
final colonia = _selectedColonia!.nombre;
|
final colonia = _selectedColonia!.nombre;
|
||||||
|
final lat = _selectedLocation?.latitude;
|
||||||
|
final lng = _selectedLocation?.longitude;
|
||||||
|
|
||||||
// 1. Registra al usuario
|
try {
|
||||||
await ref
|
await ref
|
||||||
.read(authControllerProvider.notifier)
|
.read(authControllerProvider.notifier)
|
||||||
.register(
|
.register(
|
||||||
email: _emailCtrl.text.trim(),
|
email: _emailCtrl.text.trim(),
|
||||||
phone: _telefonoCtrl.text.trim(),
|
phone: phone,
|
||||||
password: _passCtrl.text,
|
password: _passCtrl.text,
|
||||||
);
|
addressCalle: calle,
|
||||||
|
addressColonia: colonia,
|
||||||
|
addressLabel: 'Mi Casa',
|
||||||
|
addressLat: lat,
|
||||||
|
addressLng: lng,
|
||||||
|
);
|
||||||
|
|
||||||
// Si el widget ya no está montado, GoRouter nos redirigió automáticamente al Home por éxito.
|
// Guardado silencioso de la dirección tras un registro exitoso
|
||||||
if (!mounted) {
|
_postAddressInBackground(calle, colonia, lat, lng);
|
||||||
_postAddressInBackground(calle, colonia);
|
} catch (_) {
|
||||||
return;
|
// El error ya es manejado por el listener y muestra el SnackBar
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Si seguimos aquí, verificar si hubo un error (ej. contraseña corta)
|
Future<void> _postAddressInBackground(
|
||||||
if (ref.read(authControllerProvider).hasError) return;
|
String calle,
|
||||||
|
String colonia,
|
||||||
|
double? lat,
|
||||||
|
double? lng,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
const storage = FlutterSecureStorage();
|
||||||
|
await Future.delayed(
|
||||||
|
const Duration(milliseconds: 800),
|
||||||
|
); // Esperar a que se guarde el JWT
|
||||||
|
final token = await storage.read(key: authTokenStorageKey) ?? '';
|
||||||
|
|
||||||
// Fallback: guardar dirección y navegar manualmente
|
if (token.isNotEmpty) {
|
||||||
await _postAddressInBackground(calle, colonia);
|
final dio = Dio(
|
||||||
if (mounted) {
|
BaseOptions(
|
||||||
context.go('/home');
|
baseUrl: const String.fromEnvironment(
|
||||||
|
'API_BASE_URL',
|
||||||
|
defaultValue: 'http://localhost:8000',
|
||||||
|
),
|
||||||
|
headers: {'Authorization': 'Bearer $token'},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await dio.post(
|
||||||
|
'/addresses',
|
||||||
|
data: {'label': 'Mi Casa', 'calle': calle, 'colonia': colonia},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Aviso: No se pudo crear la dirección: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,12 +407,7 @@ class _Step1 extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
AppFormField(
|
_PhoneField(controller: telefonoCtrl),
|
||||||
label: 'Teléfono',
|
|
||||||
hint: '+52 461 123 4567',
|
|
||||||
controller: telefonoCtrl,
|
|
||||||
keyboardType: TextInputType.phone,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
AppFormField(
|
AppFormField(
|
||||||
label: 'Contraseña',
|
label: 'Contraseña',
|
||||||
@@ -523,14 +525,6 @@ class _Step2 extends StatelessWidget {
|
|||||||
|
|
||||||
final mapCenter = selectedLocation ?? baseCenter;
|
final mapCenter = selectedLocation ?? baseCenter;
|
||||||
|
|
||||||
// Magia de privacidad: Restringir paneo a 1km a la redonda usando el centro original
|
|
||||||
final bounds = selectedColonia != null
|
|
||||||
? LatLngBounds(
|
|
||||||
LatLng(baseCenter.latitude - 0.01, baseCenter.longitude - 0.01),
|
|
||||||
LatLng(baseCenter.latitude + 0.01, baseCenter.longitude + 0.01),
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -611,11 +605,13 @@ class _Step2 extends StatelessWidget {
|
|||||||
size: 18,
|
size: 18,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Expanded(
|
||||||
'Colonia: ${selectedColonia!.nombre}',
|
child: Text(
|
||||||
style: const TextStyle(
|
'Colonia: ${selectedColonia!.nombre}',
|
||||||
fontWeight: FontWeight.w600,
|
style: const TextStyle(
|
||||||
color: AppTheme.primaryDark,
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.primaryDark,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -668,9 +664,6 @@ class _Step2 extends StatelessWidget {
|
|||||||
options: MapOptions(
|
options: MapOptions(
|
||||||
initialCenter: mapCenter,
|
initialCenter: mapCenter,
|
||||||
initialZoom: 15.0,
|
initialZoom: 15.0,
|
||||||
cameraConstraint: bounds != null
|
|
||||||
? CameraConstraint.containCenter(bounds: bounds)
|
|
||||||
: const CameraConstraint.unconstrained(),
|
|
||||||
onTap: (_, latlng) => onLocationChanged(latlng),
|
onTap: (_, latlng) => onLocationChanged(latlng),
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
@@ -807,7 +800,10 @@ class _Step2 extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.check, size: 18),
|
Icon(Icons.check, size: 18),
|
||||||
SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
Text('Registrarme'),
|
Flexible(
|
||||||
|
child: Text('Registrarme',
|
||||||
|
overflow: TextOverflow.ellipsis),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -831,6 +827,153 @@ class _Step2 extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Campo de teléfono con lada ────────────────────────────────────────────────
|
||||||
|
// Muestra +52 🇲🇽 fijo (escalable a selector multi-país en el futuro).
|
||||||
|
// Formatea la entrada como 000-000-0000 y valida exactamente 10 dígitos.
|
||||||
|
class _PhoneField extends StatelessWidget {
|
||||||
|
final TextEditingController controller;
|
||||||
|
const _PhoneField({required this.controller});
|
||||||
|
|
||||||
|
// Países disponibles (lista para escalamiento futuro)
|
||||||
|
static const _ladas = [(flag: '🇲🇽', code: '+52', name: 'México')];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final lada = _ladas.first;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Teléfono',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Selector de lada (por ahora solo +52)
|
||||||
|
Container(
|
||||||
|
height: 50,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.background,
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
|
||||||
|
border: Border.all(color: AppTheme.border),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(lada.flag, style: const TextStyle(fontSize: 20)),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
lada.code,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
// Número (solo dígitos, formato 000-000-0000)
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: controller,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
LengthLimitingTextInputFormatter(10),
|
||||||
|
_PhoneInputFormatter(),
|
||||||
|
],
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: '000-000-0000',
|
||||||
|
hintStyle: const TextStyle(
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: AppTheme.background,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 14,
|
||||||
|
vertical: 15,
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
|
||||||
|
borderSide: const BorderSide(color: AppTheme.border),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
|
||||||
|
borderSide: const BorderSide(color: AppTheme.border),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
|
||||||
|
borderSide: const BorderSide(
|
||||||
|
color: AppTheme.primary,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
errorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
|
||||||
|
borderSide: const BorderSide(color: AppTheme.danger),
|
||||||
|
),
|
||||||
|
focusedErrorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
|
||||||
|
borderSide: const BorderSide(
|
||||||
|
color: AppTheme.danger,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
validator: (v) {
|
||||||
|
if (v == null || v.isEmpty) return null; // opcional
|
||||||
|
final digits = v.replaceAll('-', '');
|
||||||
|
if (digits.length != 10)
|
||||||
|
return 'Ingresa exactamente 10 dígitos';
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formatea dígitos en tiempo real: 4611234567 → 461-123-4567
|
||||||
|
class _PhoneInputFormatter extends TextInputFormatter {
|
||||||
|
@override
|
||||||
|
TextEditingValue formatEditUpdate(
|
||||||
|
TextEditingValue oldValue,
|
||||||
|
TextEditingValue newValue,
|
||||||
|
) {
|
||||||
|
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
|
||||||
|
final String formatted;
|
||||||
|
if (digits.length <= 3) {
|
||||||
|
formatted = digits;
|
||||||
|
} else if (digits.length <= 6) {
|
||||||
|
formatted = '${digits.substring(0, 3)}-${digits.substring(3)}';
|
||||||
|
} else {
|
||||||
|
formatted =
|
||||||
|
'${digits.substring(0, 3)}-${digits.substring(3, 6)}-${digits.substring(6)}';
|
||||||
|
}
|
||||||
|
return TextEditingValue(
|
||||||
|
text: formatted,
|
||||||
|
selection: TextSelection.collapsed(offset: formatted.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Opción radio ──────────────────────────────────────────────────────────────
|
// ── Opción radio ──────────────────────────────────────────────────────────────
|
||||||
class _RadioOption extends StatelessWidget {
|
class _RadioOption extends StatelessWidget {
|
||||||
final int value, groupValue;
|
final int value, groupValue;
|
||||||
|
|||||||
73
recolecta_app/lib/features/eta/eta_model.dart
Normal file
73
recolecta_app/lib/features/eta/eta_model.dart
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// lib/features/eta/eta_model.dart
|
||||||
|
// Modelo de respuesta del endpoint GET /eta?address_id=X
|
||||||
|
// El backend NUNCA devuelve coordenadas; solo texto y status.
|
||||||
|
|
||||||
|
enum RouteStatus {
|
||||||
|
pendiente,
|
||||||
|
enRuta,
|
||||||
|
completada,
|
||||||
|
diferida,
|
||||||
|
reasignada,
|
||||||
|
}
|
||||||
|
|
||||||
|
RouteStatus routeStatusFromString(String s) {
|
||||||
|
switch (s) {
|
||||||
|
case 'en_ruta':
|
||||||
|
return RouteStatus.enRuta;
|
||||||
|
case 'completada':
|
||||||
|
return RouteStatus.completada;
|
||||||
|
case 'diferida':
|
||||||
|
return RouteStatus.diferida;
|
||||||
|
case 'reasignada':
|
||||||
|
return RouteStatus.reasignada;
|
||||||
|
default:
|
||||||
|
return RouteStatus.pendiente;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EtaResponse {
|
||||||
|
/// Texto accionable que muestra el ciudadano.
|
||||||
|
/// Ejemplos: "Llega en aproximadamente 15 minutos"
|
||||||
|
/// "Servicio del día finalizado"
|
||||||
|
final String mensaje;
|
||||||
|
|
||||||
|
/// Estado de la ruta para mostrar el badge correcto.
|
||||||
|
final RouteStatus status;
|
||||||
|
|
||||||
|
/// Ventana horaria opcional, ej. "7:20–7:35 p.m."
|
||||||
|
/// Solo presente cuando positionId == 4 (TRUCK_PROXIMITY).
|
||||||
|
final String? ventanaHoraria;
|
||||||
|
|
||||||
|
const EtaResponse({
|
||||||
|
required this.mensaje,
|
||||||
|
required this.status,
|
||||||
|
this.ventanaHoraria,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory EtaResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
return EtaResponse(
|
||||||
|
mensaje: json['mensaje'] as String,
|
||||||
|
status: routeStatusFromString(json['status'] as String),
|
||||||
|
ventanaHoraria: json['ventana_horaria'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Estado de progreso local (0-3) mapeado al positionId del backend.
|
||||||
|
/// Útil para la barra de 4 pasos en la UI.
|
||||||
|
int get stepIndex {
|
||||||
|
switch (status) {
|
||||||
|
case RouteStatus.pendiente:
|
||||||
|
return 0;
|
||||||
|
case RouteStatus.enRuta:
|
||||||
|
return 1;
|
||||||
|
case RouteStatus.completada:
|
||||||
|
return 3;
|
||||||
|
default:
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isCompleted => status == RouteStatus.completada;
|
||||||
|
bool get isNearby =>
|
||||||
|
ventanaHoraria != null && status == RouteStatus.enRuta;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
import '../../core/constants/auth_constants.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../core/models/ui_models.dart';
|
import '../../core/models/ui_models.dart';
|
||||||
import 'colonias_data.dart';
|
import 'colonias_data.dart';
|
||||||
@@ -30,8 +31,8 @@ class _CitizenHomeScreenState extends State<CitizenHomeScreen> {
|
|||||||
Future<void> _loadData() async {
|
Future<void> _loadData() async {
|
||||||
try {
|
try {
|
||||||
const storage = FlutterSecureStorage();
|
const storage = FlutterSecureStorage();
|
||||||
final token = await storage.read(key: 'token') ?? '';
|
final token = await storage.read(key: authTokenStorageKey) ?? '';
|
||||||
|
|
||||||
if (token.isEmpty) {
|
if (token.isEmpty) {
|
||||||
if (mounted) setState(() => _isLoading = false);
|
if (mounted) setState(() => _isLoading = false);
|
||||||
return;
|
return;
|
||||||
@@ -53,7 +54,8 @@ class _CitizenHomeScreenState extends State<CitizenHomeScreen> {
|
|||||||
if (colRes.data is List) {
|
if (colRes.data is List) {
|
||||||
for (var c in colRes.data) {
|
for (var c in colRes.data) {
|
||||||
final nombre = c['nombre'] ?? c['colonia'] ?? '';
|
final nombre = c['nombre'] ?? c['colonia'] ?? '';
|
||||||
final horario = c['horario_estimado'] ?? c['schedule'] ?? 'Horario no definido';
|
final horario =
|
||||||
|
c['horario_estimado'] ?? c['schedule'] ?? 'Horario no definido';
|
||||||
if (nombre.isNotEmpty) {
|
if (nombre.isNotEmpty) {
|
||||||
_horarios[nombre] = horario;
|
_horarios[nombre] = horario;
|
||||||
}
|
}
|
||||||
@@ -67,14 +69,19 @@ class _CitizenHomeScreenState extends State<CitizenHomeScreen> {
|
|||||||
final res = await dio.get('/addresses');
|
final res = await dio.get('/addresses');
|
||||||
List<UIHouseModel> loadedCasas = [];
|
List<UIHouseModel> loadedCasas = [];
|
||||||
if (res.data is List) {
|
if (res.data is List) {
|
||||||
loadedCasas = (res.data as List).map((e) => UIHouseModel.fromJson(e)).toList();
|
loadedCasas = (res.data as List)
|
||||||
|
.map((e) => UIHouseModel.fromJson(e))
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Obtener ETA (Tiempo Estimado) para cada domicilio
|
// 3. Obtener ETA (Tiempo Estimado) para cada domicilio
|
||||||
Map<String, String> loadedEtas = {};
|
Map<String, String> loadedEtas = {};
|
||||||
for (var casa in loadedCasas) {
|
for (var casa in loadedCasas) {
|
||||||
try {
|
try {
|
||||||
final etaRes = await dio.get('/eta', queryParameters: {'address_id': casa.id});
|
final etaRes = await dio.get(
|
||||||
|
'/eta',
|
||||||
|
queryParameters: {'address_id': casa.id},
|
||||||
|
);
|
||||||
loadedEtas[casa.id] = etaRes.data['mensaje'] ?? 'Estado desconocido';
|
loadedEtas[casa.id] = etaRes.data['mensaje'] ?? 'Estado desconocido';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loadedEtas[casa.id] = 'Calculando...';
|
loadedEtas[casa.id] = 'Calculando...';
|
||||||
@@ -110,29 +117,30 @@ class _CitizenHomeScreenState extends State<CitizenHomeScreen> {
|
|||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
_loadData();
|
_loadData();
|
||||||
},
|
},
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: _isLoading
|
body: _isLoading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _casas.isEmpty
|
: _casas.isEmpty
|
||||||
? const Center(
|
? const Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'No tienes domicilios registrados.',
|
'No tienes domicilios registrados.',
|
||||||
style: TextStyle(color: AppTheme.textSecondary),
|
style: TextStyle(color: AppTheme.textSecondary),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: ListView.separated(
|
: ListView.separated(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
itemCount: _casas.length,
|
itemCount: _casas.length,
|
||||||
separatorBuilder: (_, __) => const SizedBox(height: 24),
|
separatorBuilder: (_, __) => const SizedBox(height: 24),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final casa = _casas[index];
|
final casa = _casas[index];
|
||||||
final eta = _etas[casa.id] ?? 'Actualizando...';
|
final eta = _etas[casa.id] ?? 'Actualizando...';
|
||||||
final horario = _horarios[casa.colonia] ?? 'Horario asignado a la ruta';
|
final horario =
|
||||||
return _HouseEtaCard(casa: casa, etaMsg: eta, horario: horario);
|
_horarios[casa.colonia] ?? 'Horario asignado a la ruta';
|
||||||
},
|
return _HouseEtaCard(casa: casa, etaMsg: eta, horario: horario);
|
||||||
),
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,13 +159,11 @@ class _HouseEtaCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final center = kColoniasCoordinates[casa.colonia] ?? const LatLng(20.5222, -100.8123);
|
// Si el usuario registró coordenadas, las usamos; si no, el centro de la colonia
|
||||||
|
final coloniaCenter = kColoniaCenter(casa.colonia);
|
||||||
// Restricción del mapa a la colonia (Privacidad por Diseño)
|
final pin = (casa.lat != null && casa.lng != null)
|
||||||
final bounds = LatLngBounds(
|
? LatLng(casa.lat!, casa.lng!)
|
||||||
LatLng(center.latitude - 0.01, center.longitude - 0.01),
|
: coloniaCenter;
|
||||||
LatLng(center.latitude + 0.01, center.longitude + 0.01),
|
|
||||||
);
|
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -170,15 +176,15 @@ class _HouseEtaCard extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// ── Mapa Restringido ──
|
// ── Mapa Restringido a la colonia ──
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 180,
|
height: 180,
|
||||||
child: FlutterMap(
|
child: FlutterMap(
|
||||||
options: MapOptions(
|
options: MapOptions(
|
||||||
initialCameraFit: CameraFit.bounds(bounds: bounds),
|
initialCenter: pin,
|
||||||
cameraConstraint: CameraConstraint.contain(bounds: bounds),
|
initialZoom: 16.0,
|
||||||
interactionOptions: const InteractionOptions(
|
interactionOptions: const InteractionOptions(
|
||||||
flags: InteractiveFlag.drag | InteractiveFlag.pinchZoom,
|
flags: InteractiveFlag.none,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
@@ -186,22 +192,24 @@ class _HouseEtaCard extends StatelessWidget {
|
|||||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
userAgentPackageName: 'com.onlineshack.recolecta',
|
userAgentPackageName: 'com.onlineshack.recolecta',
|
||||||
),
|
),
|
||||||
CircleLayer(
|
MarkerLayer(
|
||||||
circles: [
|
markers: [
|
||||||
CircleMarker(
|
Marker(
|
||||||
point: center,
|
point: pin,
|
||||||
color: AppTheme.primary.withValues(alpha: 0.15),
|
width: 36,
|
||||||
borderColor: AppTheme.primary,
|
height: 36,
|
||||||
borderStrokeWidth: 2,
|
child: const Icon(
|
||||||
radius: 400,
|
Icons.home_rounded,
|
||||||
useRadiusInMeter: true,
|
color: AppTheme.primary,
|
||||||
|
size: 36,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Recuadro de Información ──
|
// ── Recuadro de Información ──
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -223,11 +231,19 @@ class _HouseEtaCard extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_InfoRow(icon: Icons.location_on_outlined, title: 'Dirección', value: casa.direccionCompleta),
|
_InfoRow(
|
||||||
|
icon: Icons.location_on_outlined,
|
||||||
|
title: 'Dirección',
|
||||||
|
value: casa.direccionCompleta,
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_InfoRow(icon: Icons.schedule_outlined, title: 'Horario Habitual', value: horario),
|
_InfoRow(
|
||||||
|
icon: Icons.schedule_outlined,
|
||||||
|
title: 'Horario Habitual',
|
||||||
|
value: horario,
|
||||||
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
|
|
||||||
// ── Alerta de ETA en Tiempo Real ──
|
// ── Alerta de ETA en Tiempo Real ──
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
@@ -239,7 +255,10 @@ class _HouseEtaCard extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.local_shipping_outlined, color: AppTheme.primaryDark),
|
const Icon(
|
||||||
|
Icons.local_shipping_outlined,
|
||||||
|
color: AppTheme.primaryDark,
|
||||||
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -282,8 +301,12 @@ class _InfoRow extends StatelessWidget {
|
|||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String title;
|
final String title;
|
||||||
final String value;
|
final String value;
|
||||||
|
|
||||||
const _InfoRow({required this.icon, required this.title, required this.value});
|
const _InfoRow({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.value,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -298,12 +321,20 @@ class _InfoRow extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary, fontWeight: FontWeight.w500),
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
style: const TextStyle(fontSize: 14, color: AppTheme.textPrimary, height: 1.3),
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
// Coordenadas de referencia para el centro de cada colonia en Celaya, Gto.
|
|
||||||
// Para el MVP, estas coordenadas son fijas y coinciden con el JSON de `colonias-rutas`.
|
|
||||||
// En una versión futura, podrían venir de una API de geocodificación o de la BD.
|
|
||||||
const Map<String, LatLng> kColoniasCoordinates = {
|
const Map<String, LatLng> kColoniasCoordinates = {
|
||||||
'Zona Centro': LatLng(20.52254, -100.81153),
|
'Zona Centro': LatLng(20.52254, -100.81153),
|
||||||
'Las Arboledas': LatLng(20.51422, -100.82793),
|
'Las Arboledas': LatLng(20.51422, -100.82793),
|
||||||
@@ -12,3 +9,12 @@ const Map<String, LatLng> kColoniasCoordinates = {
|
|||||||
'Las Insurgentes': LatLng(20.52427, -100.79548),
|
'Las Insurgentes': LatLng(20.52427, -100.79548),
|
||||||
'Trojes': LatLng(20.50899, -100.77167),
|
'Trojes': LatLng(20.50899, -100.77167),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Lookup case-insensitive y sin espacios extras.
|
||||||
|
LatLng kColoniaCenter(String colonia) {
|
||||||
|
final key = colonia.trim().toLowerCase();
|
||||||
|
for (final e in kColoniasCoordinates.entries) {
|
||||||
|
if (e.key.toLowerCase() == key) return e.value;
|
||||||
|
}
|
||||||
|
return const LatLng(20.52254, -100.81153); // fallback: Zona Centro
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
import '../../core/constants/auth_constants.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../core/models/ui_models.dart';
|
import '../../core/models/ui_models.dart';
|
||||||
import 'colonias_data.dart';
|
import 'colonias_data.dart';
|
||||||
@@ -28,7 +30,7 @@ class _MyHouseScreenState extends State<MyHouseScreen> {
|
|||||||
Future<void> _cargarDomicilio() async {
|
Future<void> _cargarDomicilio() async {
|
||||||
try {
|
try {
|
||||||
const storage = FlutterSecureStorage();
|
const storage = FlutterSecureStorage();
|
||||||
final token = await storage.read(key: 'token') ?? '';
|
final token = await storage.read(key: authTokenStorageKey) ?? '';
|
||||||
|
|
||||||
if (token.isEmpty) {
|
if (token.isEmpty) {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
@@ -96,7 +98,11 @@ class _MyHouseScreenState extends State<MyHouseScreen> {
|
|||||||
_CasaCard(casa: _casa!),
|
_CasaCard(casa: _casa!),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const AppSectionTitle(title: 'Mapa del Sector (Restringido)'),
|
const AppSectionTitle(title: 'Mapa del Sector (Restringido)'),
|
||||||
_MapaColoniaRestringido(colonia: _casa!.colonia),
|
_MapaColoniaRestringido(
|
||||||
|
colonia: _casa!.colonia,
|
||||||
|
lat: _casa!.lat,
|
||||||
|
lng: _casa!.lng,
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const AppSectionTitle(title: 'Radio de alerta'),
|
const AppSectionTitle(title: 'Radio de alerta'),
|
||||||
_RadioAlertaCard(
|
_RadioAlertaCard(
|
||||||
@@ -120,13 +126,13 @@ class _MyHouseScreenState extends State<MyHouseScreen> {
|
|||||||
_HorarioCard(),
|
_HorarioCard(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => ScaffoldMessenger.of(context).showSnackBar(
|
onTap: () async {
|
||||||
const SnackBar(
|
final added = await context.push<bool>('/add-address');
|
||||||
content: Text('Funcionalidad próximamente disponible'),
|
if (added == true && mounted) {
|
||||||
behavior: SnackBarBehavior.floating,
|
setState(() => _isLoading = true);
|
||||||
backgroundColor: AppTheme.primary,
|
_cargarDomicilio();
|
||||||
),
|
}
|
||||||
),
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -251,19 +257,14 @@ class _CasaCard extends StatelessWidget {
|
|||||||
// ── Mapa de Colonia (Restringido para Privacidad) ──────────────────────────────
|
// ── Mapa de Colonia (Restringido para Privacidad) ──────────────────────────────
|
||||||
class _MapaColoniaRestringido extends StatelessWidget {
|
class _MapaColoniaRestringido extends StatelessWidget {
|
||||||
final String colonia;
|
final String colonia;
|
||||||
const _MapaColoniaRestringido({required this.colonia});
|
final double? lat;
|
||||||
|
final double? lng;
|
||||||
|
const _MapaColoniaRestringido({required this.colonia, this.lat, this.lng});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Usa las coordenadas del archivo centralizado de datos de colonias.
|
final center = kColoniaCenter(colonia);
|
||||||
final center =
|
final pin = (lat != null && lng != null) ? LatLng(lat!, lng!) : center;
|
||||||
kColoniasCoordinates[colonia] ?? const LatLng(20.5222, -100.8123);
|
|
||||||
|
|
||||||
// Creamos una "caja" o límite geográfico de aprox 1km a la redonda
|
|
||||||
final bounds = LatLngBounds(
|
|
||||||
LatLng(center.latitude - 0.01, center.longitude - 0.01),
|
|
||||||
LatLng(center.latitude + 0.01, center.longitude + 0.01),
|
|
||||||
);
|
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
height: 200,
|
height: 200,
|
||||||
@@ -274,11 +275,10 @@ class _MapaColoniaRestringido extends StatelessWidget {
|
|||||||
clipBehavior: Clip.hardEdge,
|
clipBehavior: Clip.hardEdge,
|
||||||
child: FlutterMap(
|
child: FlutterMap(
|
||||||
options: MapOptions(
|
options: MapOptions(
|
||||||
initialCameraFit: CameraFit.bounds(bounds: bounds),
|
initialCenter: pin,
|
||||||
// ESTO ES LA MAGIA DE LA PRIVACIDAD: Bloquea el mapa a esta caja
|
initialZoom: 16.0,
|
||||||
cameraConstraint: CameraConstraint.contain(bounds: bounds),
|
|
||||||
interactionOptions: const InteractionOptions(
|
interactionOptions: const InteractionOptions(
|
||||||
flags: InteractiveFlag.drag | InteractiveFlag.pinchZoom,
|
flags: InteractiveFlag.none,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
@@ -286,15 +286,17 @@ class _MapaColoniaRestringido extends StatelessWidget {
|
|||||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
userAgentPackageName: 'com.onlineshack.recolecta',
|
userAgentPackageName: 'com.onlineshack.recolecta',
|
||||||
),
|
),
|
||||||
CircleLayer(
|
MarkerLayer(
|
||||||
circles: [
|
markers: [
|
||||||
CircleMarker(
|
Marker(
|
||||||
point: center,
|
point: pin,
|
||||||
color: AppTheme.primary.withValues(alpha: 0.2),
|
width: 36,
|
||||||
borderColor: AppTheme.primary,
|
height: 36,
|
||||||
borderStrokeWidth: 2,
|
child: const Icon(
|
||||||
radius: 350, // 350 metros a la redonda remarcados
|
Icons.home_rounded,
|
||||||
useRadiusInMeter: true,
|
color: AppTheme.primary,
|
||||||
|
size: 36,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
125
recolecta_app/lib/features/profile/edit_profile_screen.dart
Normal file
125
recolecta_app/lib/features/profile/edit_profile_screen.dart
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:recolecta_app/core/theme/app_theme.dart';
|
||||||
|
import 'package:recolecta_app/core/widgets/app_widgets.dart';
|
||||||
|
import 'package:recolecta_app/core/services/auth_controller.dart';
|
||||||
|
import 'package:recolecta_app/core/api/api_service.dart';
|
||||||
|
|
||||||
|
class EditProfileScreen extends ConsumerStatefulWidget {
|
||||||
|
const EditProfileScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ConsumerStatefulWidget> createState() =>
|
||||||
|
_EditProfileScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EditProfileScreenState extends ConsumerState<EditProfileScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _nameController = TextEditingController();
|
||||||
|
final _emailController = TextEditingController();
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// TODO: Si deseas pre-llenar los datos, aquí puedes llamar a tu API
|
||||||
|
// (ej. GET /users/me) usando ref.read(apiServiceProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nameController.dispose();
|
||||||
|
_emailController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveProfile() async {
|
||||||
|
if (!_formKey.currentState!.validate()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final apiService = ref.read(apiServiceProvider);
|
||||||
|
await apiService.updateUser({
|
||||||
|
'name': _nameController.text,
|
||||||
|
'email': _emailController.text,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Perfil actualizado con éxito')),
|
||||||
|
);
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Error al actualizar el perfil: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Editar Perfil'),
|
||||||
|
actions: [
|
||||||
|
if (_isLoading)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(right: 16.0),
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
TextButton(onPressed: _saveProfile, child: const Text('Guardar')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
children: [
|
||||||
|
TextFormField(
|
||||||
|
controller: _nameController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Nombre',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Por favor ingresa tu nombre';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _emailController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Correo Electrónico',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || !value.contains('@')) {
|
||||||
|
return 'Por favor ingresa un correo válido';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,8 @@ class ProfileScreen extends ConsumerWidget {
|
|||||||
body: FutureBuilder<_ProfileData>(
|
body: FutureBuilder<_ProfileData>(
|
||||||
future: _loadProfile(storage),
|
future: _loadProfile(storage),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final profile = snapshot.data ??
|
final profile =
|
||||||
|
snapshot.data ??
|
||||||
_ProfileData(
|
_ProfileData(
|
||||||
email: authState?.token != null ? '…' : '',
|
email: authState?.token != null ? '…' : '',
|
||||||
role: authState?.userRole ?? 'citizen',
|
role: authState?.userRole ?? 'citizen',
|
||||||
@@ -39,7 +40,7 @@ class ProfileScreen extends ConsumerWidget {
|
|||||||
icon: Icons.person_outline,
|
icon: Icons.person_outline,
|
||||||
title: 'Editar perfil',
|
title: 'Editar perfil',
|
||||||
subtitle: profile.email,
|
subtitle: profile.email,
|
||||||
onTap: () {},
|
onTap: () => context.go('/edit-profile'),
|
||||||
),
|
),
|
||||||
AppMenuTile(
|
AppMenuTile(
|
||||||
icon: Icons.lock_outline,
|
icon: Icons.lock_outline,
|
||||||
@@ -110,7 +111,10 @@ class ProfileScreen extends ConsumerWidget {
|
|||||||
'Recolecta v1.0.0\nServicio de Limpia · Celaya, Gto.',
|
'Recolecta v1.0.0\nServicio de Limpia · Celaya, Gto.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12, color: AppTheme.textHint, height: 1.6),
|
fontSize: 12,
|
||||||
|
color: AppTheme.textHint,
|
||||||
|
height: 1.6,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
@@ -133,19 +137,26 @@ class ProfileScreen extends ConsumerWidget {
|
|||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
backgroundColor: AppTheme.surface,
|
backgroundColor: AppTheme.surface,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(AppTheme.radiusLg)),
|
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
||||||
title: const Text('Cerrar sesión',
|
),
|
||||||
style: TextStyle(
|
title: const Text(
|
||||||
fontSize: 17,
|
'Cerrar sesión',
|
||||||
fontWeight: FontWeight.w700,
|
style: TextStyle(
|
||||||
color: AppTheme.textPrimary)),
|
fontSize: 17,
|
||||||
content: const Text('¿Estás seguro de que deseas cerrar sesión?',
|
fontWeight: FontWeight.w700,
|
||||||
style: TextStyle(fontSize: 14, color: AppTheme.textSecondary)),
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
content: const Text(
|
||||||
|
'¿Estás seguro de que deseas cerrar sesión?',
|
||||||
|
style: TextStyle(fontSize: 14, color: AppTheme.textSecondary),
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(ctx),
|
onPressed: () => Navigator.pop(ctx),
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
foregroundColor: AppTheme.textSecondary),
|
foregroundColor: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
child: const Text('Cancelar'),
|
child: const Text('Cancelar'),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -155,8 +166,10 @@ class ProfileScreen extends ConsumerWidget {
|
|||||||
if (context.mounted) context.go('/login');
|
if (context.mounted) context.go('/login');
|
||||||
},
|
},
|
||||||
style: TextButton.styleFrom(foregroundColor: AppTheme.danger),
|
style: TextButton.styleFrom(foregroundColor: AppTheme.danger),
|
||||||
child: const Text('Cerrar sesión',
|
child: const Text(
|
||||||
style: TextStyle(fontWeight: FontWeight.w600)),
|
'Cerrar sesión',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -169,13 +182,9 @@ class _ProfileData {
|
|||||||
final String email;
|
final String email;
|
||||||
final String role;
|
final String role;
|
||||||
|
|
||||||
const _ProfileData({
|
const _ProfileData({this.email = '', this.role = 'citizen'});
|
||||||
this.email = '',
|
|
||||||
this.role = 'citizen',
|
|
||||||
});
|
|
||||||
|
|
||||||
String get iniciales =>
|
String get iniciales => email.isNotEmpty ? email[0].toUpperCase() : 'U';
|
||||||
email.isNotEmpty ? email[0].toUpperCase() : 'U';
|
|
||||||
|
|
||||||
String get displayName => email;
|
String get displayName => email;
|
||||||
bool get isAdmin => role == 'admin';
|
bool get isAdmin => role == 'admin';
|
||||||
@@ -210,9 +219,10 @@ class _ProfileHeader extends StatelessWidget {
|
|||||||
child: Text(
|
child: Text(
|
||||||
profile.iniciales,
|
profile.iniciales,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppTheme.primaryDark),
|
color: AppTheme.primaryDark,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -221,18 +231,26 @@ class _ProfileHeader extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(profile.displayName,
|
Text(
|
||||||
style: const TextStyle(
|
profile.displayName,
|
||||||
fontSize: 16,
|
style: const TextStyle(
|
||||||
fontWeight: FontWeight.w700,
|
fontSize: 16,
|
||||||
color: AppTheme.textPrimary)),
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(profile.email,
|
Text(
|
||||||
style: const TextStyle(
|
profile.email,
|
||||||
fontSize: 13, color: AppTheme.textSecondary)),
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
AppStatusBadge.green(
|
AppStatusBadge.green(
|
||||||
profile.isAdmin ? 'Administrador' : 'Ciudadano'),
|
profile.isAdmin ? 'Administrador' : 'Ciudadano',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
|
|
||||||
|
// Modelo de mensaje simple
|
||||||
|
class ChatMessage {
|
||||||
|
final String role; // 'user', 'assistant', 'system'
|
||||||
|
final String content;
|
||||||
|
|
||||||
|
ChatMessage({required this.role, required this.content});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {'role': role, 'content': content};
|
||||||
|
}
|
||||||
|
|
||||||
|
class AiChatNotifier extends StateNotifier<List<ChatMessage>> {
|
||||||
|
AiChatNotifier()
|
||||||
|
: super([
|
||||||
|
ChatMessage(
|
||||||
|
role: 'assistant',
|
||||||
|
content:
|
||||||
|
'¡Hola! Soy Eco 🍃, la mascota de Recolecta. '
|
||||||
|
'Estoy aquí para ayudarte a reciclar y separar tu basura correctamente. ¿Tienes alguna duda?',
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
bool isLoading = false;
|
||||||
|
|
||||||
|
Future<void> sendMessage(String userText) async {
|
||||||
|
if (userText.trim().isEmpty) return;
|
||||||
|
|
||||||
|
// Añadir mensaje del usuario
|
||||||
|
final userMsg = ChatMessage(role: 'user', content: userText);
|
||||||
|
state = [...state, userMsg];
|
||||||
|
isLoading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final dio = Dio();
|
||||||
|
// Importante: En producción, la llamada a OpenAI debería hacerse idealmente
|
||||||
|
// desde tu backend FastAPI para no exponer la API_KEY en la app Flutter.
|
||||||
|
// Para el MVP/Hackathon, la leemos del entorno (.env o --dart-define)
|
||||||
|
final apiKey = dotenv.env['OPENAI_API_KEY'] ?? '';
|
||||||
|
|
||||||
|
// Contexto del sistema para que la IA actúe como la mascota
|
||||||
|
final systemPrompt = ChatMessage(
|
||||||
|
role: 'system',
|
||||||
|
content:
|
||||||
|
'Eres Eco, la mascota virtual de la app Recolecta en Celaya. '
|
||||||
|
'Tu misión es educar a los ciudadanos sobre cómo separar la basura en 4 categorías: '
|
||||||
|
'Orgánicos (verde), Reciclables (azul), Sanitarios (naranja) y Especiales (morado). '
|
||||||
|
'Responde siempre de forma muy amigable, entusiasta, usando emojis. '
|
||||||
|
'Sé muy conciso y breve (máximo 3 oraciones cortas). '
|
||||||
|
'Nunca reveles ubicaciones de camiones ni te salgas del tema del reciclaje y medio ambiente.',
|
||||||
|
);
|
||||||
|
|
||||||
|
final messagesForApi = [systemPrompt, ...state];
|
||||||
|
|
||||||
|
final response = await dio.post(
|
||||||
|
'https://api.openai.com/v1/chat/completions',
|
||||||
|
options: Options(
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer $apiKey',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
data: {
|
||||||
|
'model': 'gpt-3.5-turbo', // Rápido y económico para el hackathon
|
||||||
|
'messages': messagesForApi.map((m) => m.toJson()).toList(),
|
||||||
|
'temperature': 0.7,
|
||||||
|
'max_tokens': 150, // Limitar para que sea conciso
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final botReply = response.data['choices'][0]['message']['content'];
|
||||||
|
state = [...state, ChatMessage(role: 'assistant', content: botReply)];
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error en OpenAI: $e');
|
||||||
|
state = [
|
||||||
|
...state,
|
||||||
|
ChatMessage(
|
||||||
|
role: 'assistant',
|
||||||
|
content:
|
||||||
|
'Uy, tuve un problemita técnico con mi cerebro de hojitas 🧠🍂. ¿Me repites tu pregunta?',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final aiChatProvider = StateNotifierProvider<AiChatNotifier, List<ChatMessage>>(
|
||||||
|
(ref) {
|
||||||
|
return AiChatNotifier();
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
// Importa Lottie si tus animaciones están en formato Lottie (.json)
|
||||||
|
// import 'package:lottie/lottie.dart';
|
||||||
|
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import 'ai_chat_provider.dart';
|
||||||
|
|
||||||
|
class AiPetChatScreen extends ConsumerStatefulWidget {
|
||||||
|
const AiPetChatScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AiPetChatScreen> createState() => _AiPetChatScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AiPetChatScreenState extends ConsumerState<AiPetChatScreen> {
|
||||||
|
final _textController = TextEditingController();
|
||||||
|
final _scrollController = ScrollController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_textController.dispose();
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _sendMessage() async {
|
||||||
|
final text = _textController.text;
|
||||||
|
if (text.trim().isEmpty) return;
|
||||||
|
|
||||||
|
_textController.clear();
|
||||||
|
|
||||||
|
// Ocultar teclado
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
|
|
||||||
|
// Enviar al provider
|
||||||
|
await ref.read(aiChatProvider.notifier).sendMessage(text);
|
||||||
|
|
||||||
|
// Hacer scroll hacia abajo
|
||||||
|
_scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scrollToBottom() {
|
||||||
|
if (_scrollController.hasClients) {
|
||||||
|
Future.delayed(const Duration(milliseconds: 300), () {
|
||||||
|
_scrollController.animateTo(
|
||||||
|
_scrollController.position.maxScrollExtent,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final messages = ref.watch(aiChatProvider);
|
||||||
|
// No podemos leer isLoading directamente de ref.watch(provider) porque es StateNotifierProvider.
|
||||||
|
// Para leer la variable, leemos el notifier.
|
||||||
|
final isLoading = ref.watch(aiChatProvider.notifier).isLoading;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppTheme.background,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Pregúntale a Eco 🍃'),
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
// 1. ÁREA DE LA MASCOTA (Animación)
|
||||||
|
Container(
|
||||||
|
height: 150,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppTheme.primaryLight,
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
bottomLeft: Radius.circular(30),
|
||||||
|
bottomRight: Radius.circular(30),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
// Reemplaza este Icono con tu animación de Lottie:
|
||||||
|
// child: Lottie.asset('assets/animations/mascota_feliz.json', height: 120),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.pets, size: 64, color: AppTheme.primary),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
isLoading ? 'Eco está pensando...' : 'Eco te escucha',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppTheme.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 2. HISTORIAL DE CHAT
|
||||||
|
Expanded(
|
||||||
|
child: ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: messages.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final msg = messages[index];
|
||||||
|
if (msg.role == 'system') return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final isBot = msg.role == 'assistant';
|
||||||
|
return _ChatBubble(text: msg.content, isBot: isBot);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Indicador de escritura
|
||||||
|
if (isLoading)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(8.0),
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 3. CAMPO DE TEXTO
|
||||||
|
SafeArea(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
color: Colors.white,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _textController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Ej. ¿Dónde tiro las cajas de pizza?',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade200,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _sendMessage(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
CircleAvatar(
|
||||||
|
backgroundColor: AppTheme.primary,
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.send,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
onPressed: isLoading ? null : _sendMessage,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChatBubble extends StatelessWidget {
|
||||||
|
final String text;
|
||||||
|
final bool isBot;
|
||||||
|
|
||||||
|
const _ChatBubble({required this.text, required this.isBot});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Align(
|
||||||
|
alignment: isBot ? Alignment.centerLeft : Alignment.centerRight,
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: MediaQuery.of(context).size.width * 0.75,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isBot ? Colors.white : AppTheme.primary,
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topLeft: const Radius.circular(20),
|
||||||
|
topRight: const Radius.circular(20),
|
||||||
|
bottomLeft: isBot ? Radius.zero : const Radius.circular(20),
|
||||||
|
bottomRight: isBot ? const Radius.circular(20) : Radius.zero,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 5,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isBot ? AppTheme.textPrimary : Colors.white,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
747
views_v3/admin_screen.dart
Normal file
747
views_v3/admin_screen.dart
Normal file
@@ -0,0 +1,747 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../models/models.dart';
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
|
import '../widgets/widgets.dart' as w;
|
||||||
|
import 'splash_screen.dart';
|
||||||
|
|
||||||
|
class AdminScreen extends StatefulWidget {
|
||||||
|
final UserModel usuario;
|
||||||
|
const AdminScreen({super.key, required this.usuario});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AdminScreen> createState() => _AdminScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdminScreenState extends State<AdminScreen> {
|
||||||
|
int _currentTab = 1;
|
||||||
|
int _selectedSection = 0;
|
||||||
|
|
||||||
|
final List<UserModel> _usuarios = [
|
||||||
|
const UserModel(
|
||||||
|
id: 'user-01',
|
||||||
|
nombre: 'Ana',
|
||||||
|
apellido: 'López',
|
||||||
|
email: 'ana.lopez@rutaverde.com',
|
||||||
|
telefono: '+52 461 111 2233',
|
||||||
|
),
|
||||||
|
const UserModel(
|
||||||
|
id: 'user-02',
|
||||||
|
nombre: 'Luis',
|
||||||
|
apellido: 'Ramírez',
|
||||||
|
email: 'luis.ramirez@rutaverde.com',
|
||||||
|
telefono: '+52 461 222 3344',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
final List<DriverModel> _conductores = [
|
||||||
|
const DriverModel(
|
||||||
|
id: 'driver-01',
|
||||||
|
nombre: 'María Pérez',
|
||||||
|
telefono: '+52 461 333 4455',
|
||||||
|
placa: 'TRD-451',
|
||||||
|
rutaAsignada: 'Ruta Norte',
|
||||||
|
),
|
||||||
|
const DriverModel(
|
||||||
|
id: 'driver-02',
|
||||||
|
nombre: 'Jorge Torres',
|
||||||
|
telefono: '+52 461 444 5566',
|
||||||
|
placa: 'TRD-752',
|
||||||
|
rutaAsignada: 'Ruta Sur',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
final List<TruckModel> _camiones = [
|
||||||
|
const TruckModel(
|
||||||
|
id: 'truck-01',
|
||||||
|
placa: 'TRD-451',
|
||||||
|
ruta: 'Ruta Norte',
|
||||||
|
conductorId: 'driver-01',
|
||||||
|
activo: true,
|
||||||
|
),
|
||||||
|
const TruckModel(
|
||||||
|
id: 'truck-02',
|
||||||
|
placa: 'TRD-752',
|
||||||
|
ruta: 'Ruta Sur',
|
||||||
|
conductorId: 'driver-02',
|
||||||
|
activo: false,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
void _selectSection(int index) {
|
||||||
|
setState(() => _selectedSection = index);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onAddPressed() {
|
||||||
|
switch (_selectedSection) {
|
||||||
|
case 0:
|
||||||
|
_showUserForm();
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
_showDriverForm();
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
_showTruckForm();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showUserForm({UserModel? user}) {
|
||||||
|
final nombre = TextEditingController(text: user?.nombre ?? '');
|
||||||
|
final apellido = TextEditingController(text: user?.apellido ?? '');
|
||||||
|
final email = TextEditingController(text: user?.email ?? '');
|
||||||
|
final telefono = TextEditingController(text: user?.telefono ?? '');
|
||||||
|
final formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(user == null ? 'Agregar usuario' : 'Editar usuario'),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
||||||
|
),
|
||||||
|
content: Form(
|
||||||
|
key: formKey,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_buildTextField('Nombre', nombre),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildTextField('Apellido', apellido),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildTextField('Email', email, keyboardType: TextInputType.emailAddress),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildTextField('Teléfono', telefono, keyboardType: TextInputType.phone),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Cancelar'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (!formKey.currentState!.validate()) return;
|
||||||
|
final nuevo = UserModel(
|
||||||
|
id: user?.id ?? 'user-${DateTime.now().millisecondsSinceEpoch}',
|
||||||
|
nombre: nombre.text.trim(),
|
||||||
|
apellido: apellido.text.trim(),
|
||||||
|
email: email.text.trim(),
|
||||||
|
telefono: telefono.text.trim(),
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
if (user == null) {
|
||||||
|
_usuarios.add(nuevo);
|
||||||
|
} else {
|
||||||
|
final index = _usuarios.indexWhere((u) => u.id == user.id);
|
||||||
|
if (index != -1) {
|
||||||
|
_usuarios[index] = nuevo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
},
|
||||||
|
child: const Text('Guardar'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showDriverForm({DriverModel? conductor}) {
|
||||||
|
final nombre = TextEditingController(text: conductor?.nombre ?? '');
|
||||||
|
final telefono = TextEditingController(text: conductor?.telefono ?? '');
|
||||||
|
final placa = TextEditingController(text: conductor?.placa ?? '');
|
||||||
|
final ruta = TextEditingController(text: conductor?.rutaAsignada ?? '');
|
||||||
|
final formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(conductor == null ? 'Agregar conductor' : 'Editar conductor'),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
||||||
|
),
|
||||||
|
content: Form(
|
||||||
|
key: formKey,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_buildTextField('Nombre', nombre),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildTextField('Teléfono', telefono, keyboardType: TextInputType.phone),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildTextField('Placa', placa),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildTextField('Ruta asignada', ruta),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Cancelar'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (!formKey.currentState!.validate()) return;
|
||||||
|
final nuevo = DriverModel(
|
||||||
|
id: conductor?.id ?? 'driver-${DateTime.now().millisecondsSinceEpoch}',
|
||||||
|
nombre: nombre.text.trim(),
|
||||||
|
telefono: telefono.text.trim(),
|
||||||
|
placa: placa.text.trim(),
|
||||||
|
rutaAsignada: ruta.text.trim(),
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
if (conductor == null) {
|
||||||
|
_conductores.add(nuevo);
|
||||||
|
} else {
|
||||||
|
final index = _conductores.indexWhere((d) => d.id == conductor.id);
|
||||||
|
if (index != -1) {
|
||||||
|
_conductores[index] = nuevo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
},
|
||||||
|
child: const Text('Guardar'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showTruckForm({TruckModel? camion}) {
|
||||||
|
final placa = TextEditingController(text: camion?.placa ?? '');
|
||||||
|
final ruta = TextEditingController(text: camion?.ruta ?? '');
|
||||||
|
var activo = camion?.activo ?? true;
|
||||||
|
String conductorId = camion?.conductorId ?? _conductores.first.id;
|
||||||
|
final formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (ctx, setStateDialog) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(camion == null ? 'Agregar camión' : 'Editar camión'),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
||||||
|
),
|
||||||
|
content: Form(
|
||||||
|
key: formKey,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_buildTextField('Placa', placa),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildTextField('Ruta', ruta),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
initialValue: conductorId,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Conductor',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
items: _conductores
|
||||||
|
.map((d) => DropdownMenuItem(
|
||||||
|
value: d.id,
|
||||||
|
child: Text(d.nombre),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
setStateDialog(() => conductorId = value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text('Activo', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
Switch(
|
||||||
|
value: activo,
|
||||||
|
onChanged: (value) => setStateDialog(() => activo = value),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Cancelar'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (!formKey.currentState!.validate()) return;
|
||||||
|
final nuevo = TruckModel(
|
||||||
|
id: camion?.id ?? 'truck-${DateTime.now().millisecondsSinceEpoch}',
|
||||||
|
placa: placa.text.trim(),
|
||||||
|
ruta: ruta.text.trim(),
|
||||||
|
conductorId: conductorId,
|
||||||
|
activo: activo,
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
if (camion == null) {
|
||||||
|
_camiones.add(nuevo);
|
||||||
|
} else {
|
||||||
|
final index = _camiones.indexWhere((t) => t.id == camion.id);
|
||||||
|
if (index != -1) {
|
||||||
|
_camiones[index] = nuevo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
},
|
||||||
|
child: const Text('Guardar'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTextField(String label, TextEditingController controller,
|
||||||
|
{TextInputType keyboardType = TextInputType.text}) {
|
||||||
|
return TextFormField(
|
||||||
|
controller: controller,
|
||||||
|
keyboardType: keyboardType,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return 'Debe completar este campo';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _confirmDelete<T>(T item, List<T> lista, String tipo) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text('Eliminar $tipo'),
|
||||||
|
content: Text('¿Seguro que deseas eliminar este $tipo?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Cancelar'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() => lista.remove(item));
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
},
|
||||||
|
style: TextButton.styleFrom(foregroundColor: AppTheme.danger),
|
||||||
|
child: const Text('Eliminar'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!widget.usuario.isAdmin) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppTheme.background,
|
||||||
|
appBar: AppBar(title: const Text('Acceso denegado')),
|
||||||
|
body: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Este panel solo está disponible para administradores.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 16, color: AppTheme.textPrimary),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pushAndRemoveUntil(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const SplashScreen()),
|
||||||
|
(_) => false,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Volver al inicio'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppTheme.background,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(_currentTab == 0 ? 'Mi perfil' : 'Panel administrador'),
|
||||||
|
actions: _currentTab == 1
|
||||||
|
? [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
onPressed: _onAddPressed,
|
||||||
|
tooltip: 'Agregar',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
body: IndexedStack(
|
||||||
|
index: _currentTab,
|
||||||
|
children: [
|
||||||
|
_buildAdminProfile(),
|
||||||
|
_buildAdminBody(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
bottomNavigationBar: BottomNavigationBar(
|
||||||
|
currentIndex: _currentTab,
|
||||||
|
onTap: (value) => setState(() => _currentTab = value),
|
||||||
|
items: const [
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.person_outline),
|
||||||
|
label: 'Perfil',
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.admin_panel_settings_outlined),
|
||||||
|
label: 'Admin',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAdminBody() {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
_buildSectionButton('Usuarios', 0),
|
||||||
|
_buildSectionButton('Conductores', 1),
|
||||||
|
_buildSectionButton('Camiones', 2),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: _buildCurrentSection(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAdminProfile() {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
w.AppCard(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryLight,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
widget.usuario.iniciales,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppTheme.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(widget.usuario.nombreCompleto,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
)),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(widget.usuario.email,
|
||||||
|
style: const TextStyle(color: AppTheme.textSecondary)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(widget.usuario.telefono,
|
||||||
|
style: const TextStyle(color: AppTheme.textSecondary)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
w.StatusBadge.green('Administrador'),
|
||||||
|
w.StatusBadge.gray(widget.usuario.role == UserRole.admin ? 'Admin' : 'Usuario'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
w.SectionTitle(title: 'Cuenta'),
|
||||||
|
w.MenuTile(
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
title: 'Editar perfil',
|
||||||
|
subtitle: widget.usuario.nombreCompleto,
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
w.MenuTile(
|
||||||
|
icon: Icons.email_outlined,
|
||||||
|
title: 'Email',
|
||||||
|
subtitle: widget.usuario.email,
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
w.MenuTile(
|
||||||
|
icon: Icons.phone_outlined,
|
||||||
|
title: 'Teléfono',
|
||||||
|
subtitle: widget.usuario.telefono,
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSectionButton(String label, int index) {
|
||||||
|
final selected = _selectedSection == index;
|
||||||
|
return Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () => _selectSection(index),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: selected ? AppTheme.primary : AppTheme.surface,
|
||||||
|
foregroundColor: selected ? Colors.white : AppTheme.textPrimary,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||||
|
),
|
||||||
|
elevation: selected ? 2 : 0,
|
||||||
|
),
|
||||||
|
child: Text(label, textAlign: TextAlign.center),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCurrentSection() {
|
||||||
|
switch (_selectedSection) {
|
||||||
|
case 0:
|
||||||
|
return _buildUsuarioSection();
|
||||||
|
case 1:
|
||||||
|
return _buildDriverSection();
|
||||||
|
default:
|
||||||
|
return _buildTruckSection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildUsuarioSection() {
|
||||||
|
if (_usuarios.isEmpty) {
|
||||||
|
return const Center(child: Text('No hay usuarios registrados.'));
|
||||||
|
}
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
itemCount: _usuarios.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final usuario = _usuarios[index];
|
||||||
|
return w.AppCard(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(usuario.nombreCompleto,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
)),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(usuario.email,
|
||||||
|
style: const TextStyle(color: AppTheme.textSecondary)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(usuario.telefono,
|
||||||
|
style: const TextStyle(color: AppTheme.textSecondary)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.edit_outlined),
|
||||||
|
onPressed: () => _showUserForm(user: usuario),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline, color: AppTheme.danger),
|
||||||
|
onPressed: () => _confirmDelete(usuario, _usuarios, 'usuario'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDriverSection() {
|
||||||
|
if (_conductores.isEmpty) {
|
||||||
|
return const Center(child: Text('No hay conductores registrados.'));
|
||||||
|
}
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
itemCount: _conductores.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final conductor = _conductores[index];
|
||||||
|
return w.AppCard(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(conductor.nombre,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
)),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(conductor.telefono,
|
||||||
|
style: const TextStyle(color: AppTheme.textSecondary)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text('${conductor.placa} · ${conductor.rutaAsignada}',
|
||||||
|
style: const TextStyle(color: AppTheme.textSecondary)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.edit_outlined),
|
||||||
|
onPressed: () => _showDriverForm(conductor: conductor),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline, color: AppTheme.danger),
|
||||||
|
onPressed: () => _confirmDelete(conductor, _conductores, 'conductor'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTruckSection() {
|
||||||
|
if (_camiones.isEmpty) {
|
||||||
|
return const Center(child: Text('No hay camiones registrados.'));
|
||||||
|
}
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
itemCount: _camiones.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final camion = _camiones[index];
|
||||||
|
final conductor = _conductores.firstWhere(
|
||||||
|
(d) => d.id == camion.conductorId,
|
||||||
|
orElse: () => const DriverModel(
|
||||||
|
id: 'none',
|
||||||
|
nombre: 'Sin conductor',
|
||||||
|
telefono: '-',
|
||||||
|
placa: '-',
|
||||||
|
rutaAsignada: '-',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return w.AppCard(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(camion.placa,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
)),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(camion.ruta,
|
||||||
|
style:
|
||||||
|
const TextStyle(color: AppTheme.textSecondary)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
w.StatusBadge.gray(camion.activo ? 'Activo' : 'Inactivo'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text('Conductor: ${conductor.nombre}',
|
||||||
|
style: const TextStyle(color: AppTheme.textSecondary)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
TextButton.icon(
|
||||||
|
icon: const Icon(Icons.edit_outlined),
|
||||||
|
label: const Text('Editar'),
|
||||||
|
onPressed: () => _showTruckForm(camion: camion),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
TextButton.icon(
|
||||||
|
icon: const Icon(Icons.delete_outline, color: AppTheme.danger),
|
||||||
|
label: const Text('Eliminar',
|
||||||
|
style: TextStyle(color: AppTheme.danger)),
|
||||||
|
onPressed: () => _confirmDelete(camion, _camiones, 'camión'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user