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."""
|
||||
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 = (
|
||||
supabase_admin.table("addresses")
|
||||
.insert(
|
||||
{
|
||||
"user_id": current_user["user_id"],
|
||||
"label": body.label,
|
||||
"calle": body.calle,
|
||||
"colonia": body.colonia,
|
||||
"route_id": route_id,
|
||||
"verified": False,
|
||||
}
|
||||
)
|
||||
.insert(insert_data)
|
||||
.execute()
|
||||
)
|
||||
|
||||
|
||||
@@ -16,12 +16,34 @@ def _fetch_role(user_id: str) -> str:
|
||||
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)
|
||||
def register(body: RegisterRequest):
|
||||
"""
|
||||
Registro por email o teléfono.
|
||||
- Email: flujo estándar Supabase email+password.
|
||||
- Teléfono: requiere que Supabase tenga configurado un proveedor SMS (Twilio).
|
||||
Registro por email o teléfono. Usa el admin client para confirmar automáticamente
|
||||
sin requerir que el usuario verifique su correo.
|
||||
"""
|
||||
if not body.email and not body.phone:
|
||||
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:
|
||||
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:
|
||||
create_attrs: dict = {"password": body.password}
|
||||
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:
|
||||
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:
|
||||
error_msg = str(e)
|
||||
if "already registered" in error_msg.lower() or "user already exists" in error_msg.lower():
|
||||
raise HTTPException(status_code=400, detail="El usuario ya está registrado en el sistema de autenticación.")
|
||||
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.")
|
||||
if "signups are disabled" in error_msg.lower():
|
||||
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)
|
||||
|
||||
auth_user = resp.user
|
||||
auth_user = admin_resp.user
|
||||
if not auth_user:
|
||||
raise HTTPException(status_code=400, detail="No se pudo crear el usuario en Supabase Auth")
|
||||
|
||||
# Crear entrada en public.users con el rol elegido
|
||||
try:
|
||||
supabase_admin.table("users").upsert(
|
||||
{
|
||||
"id": str(auth_user.id),
|
||||
"role": body.role,
|
||||
}
|
||||
{"id": str(auth_user.id), "role": body.role}
|
||||
).execute()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error al guardar el usuario: {e}")
|
||||
|
||||
# Si no hubo sesión (email confirmation pendiente)
|
||||
if not resp.session:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cuenta creada. Revisa tu correo para confirmar tu email antes de iniciar sesión.",
|
||||
)
|
||||
# Iniciar sesión para obtener el JWT
|
||||
try:
|
||||
if body.email:
|
||||
session_resp = supabase.auth.sign_in_with_password(
|
||||
{"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(
|
||||
access_token=resp.session.access_token,
|
||||
access_token=session_resp.session.access_token,
|
||||
user_id=str(auth_user.id),
|
||||
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")
|
||||
|
||||
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(
|
||||
access_token=resp.session.access_token,
|
||||
user_id=str(auth_user.id),
|
||||
user_id=user_id,
|
||||
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
|
||||
calle: str
|
||||
colonia: str # el backend deriva route_id a partir de colonias-rutas.json
|
||||
lat: Optional[float] = None
|
||||
lng: Optional[float] = None
|
||||
|
||||
|
||||
class AddressResponse(BaseModel):
|
||||
@@ -19,3 +21,5 @@ class AddressResponse(BaseModel):
|
||||
verified_method: Optional[str] = None
|
||||
verified_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
|
||||
password: str
|
||||
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):
|
||||
@@ -20,3 +26,6 @@ class TokenResponse(BaseModel):
|
||||
token_type: str = "bearer"
|
||||
user_id: 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.addresses import router as addresses_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
|
||||
|
||||
scheduler = AsyncIOScheduler()
|
||||
@@ -48,8 +48,8 @@ app = FastAPI(
|
||||
# CORS — necesario para el simulador web y el cliente Flutter
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # En producción limitar a dominios reales
|
||||
allow_credentials=True,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False, # Usamos Bearer tokens, no cookies — wildcard compatible
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
@@ -58,17 +58,4 @@ app.include_router(auth_router)
|
||||
app.include_router(addresses_router)
|
||||
app.include_router(eta_router)
|
||||
app.include_router(colonias_router)
|
||||
app.include_router(simulation_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"}
|
||||
app.include_router(users_router)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "db50e20168db8fee486b9abf32fc912de3bc5b6a"
|
||||
revision: "559ffa3f75e7402d65a8def9c28389a9b2e6fe42"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
@@ -13,26 +13,26 @@ project_type: app
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||
- platform: android
|
||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||
- platform: ios
|
||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||
- platform: linux
|
||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||
- platform: macos
|
||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||
- platform: web
|
||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||
- platform: windows
|
||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||
|
||||
# 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 colonia;
|
||||
final String? routeId;
|
||||
final double? lat;
|
||||
final double? lng;
|
||||
final int radioAlertaMetros;
|
||||
final bool alertaCercana;
|
||||
final bool alertaMedia;
|
||||
@@ -45,6 +47,8 @@ class UIHouseModel {
|
||||
required this.calle,
|
||||
required this.colonia,
|
||||
this.routeId,
|
||||
this.lat,
|
||||
this.lng,
|
||||
this.radioAlertaMetros = 200,
|
||||
this.alertaCercana = true,
|
||||
this.alertaMedia = false,
|
||||
@@ -59,6 +63,8 @@ class UIHouseModel {
|
||||
String? calle,
|
||||
String? colonia,
|
||||
String? routeId,
|
||||
double? lat,
|
||||
double? lng,
|
||||
int? radioAlertaMetros,
|
||||
bool? alertaCercana,
|
||||
bool? alertaMedia,
|
||||
@@ -71,6 +77,8 @@ class UIHouseModel {
|
||||
calle: calle ?? this.calle,
|
||||
colonia: colonia ?? this.colonia,
|
||||
routeId: routeId ?? this.routeId,
|
||||
lat: lat ?? this.lat,
|
||||
lng: lng ?? this.lng,
|
||||
radioAlertaMetros: radioAlertaMetros ?? this.radioAlertaMetros,
|
||||
alertaCercana: alertaCercana ?? this.alertaCercana,
|
||||
alertaMedia: alertaMedia ?? this.alertaMedia,
|
||||
@@ -86,6 +94,8 @@ class UIHouseModel {
|
||||
calle: json['calle'] as String? ?? '',
|
||||
colonia: json['colonia'] 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/profile/profile_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/separation_guide_screen.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/notifications/notifications_screen.dart';
|
||||
import '../../features/quiz/quiz_screen.dart';
|
||||
@@ -75,6 +77,10 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
path: '/register',
|
||||
builder: (context, state) => const RegisterPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/edit-profile',
|
||||
builder: (context, state) => const EditProfileScreen(),
|
||||
),
|
||||
|
||||
// ── Admin ─────────────────────────────────────────────────────────────
|
||||
ShellRoute(
|
||||
@@ -145,6 +151,10 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
path: '/feedback',
|
||||
builder: (context, state) => const FeedbackScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/add-address',
|
||||
builder: (context, state) => const AddAddressPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/guide',
|
||||
builder: (context, state) => const SeparationGuideScreen(),
|
||||
@@ -165,10 +175,7 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
path: '/notifications',
|
||||
builder: (context, state) => const NotificationsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/quiz',
|
||||
builder: (context, state) => const QuizScreen(),
|
||||
),
|
||||
GoRoute(path: '/quiz', builder: (context, state) => const QuizScreen()),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -47,12 +47,25 @@ class AuthController extends AsyncNotifier<AuthState> {
|
||||
required String email,
|
||||
required String phone,
|
||||
required String password,
|
||||
String? addressCalle,
|
||||
String? addressColonia,
|
||||
String? addressLabel,
|
||||
double? addressLat,
|
||||
double? addressLng,
|
||||
}) async {
|
||||
state = const AsyncLoading<AuthState>();
|
||||
try {
|
||||
final session = await ref
|
||||
.read(authServiceProvider)
|
||||
.register(email: email, phone: phone, password: password);
|
||||
final session = await ref.read(authServiceProvider).register(
|
||||
email: email,
|
||||
phone: phone,
|
||||
password: password,
|
||||
addressCalle: addressCalle,
|
||||
addressColonia: addressColonia,
|
||||
addressLabel: addressLabel,
|
||||
addressLat: addressLat,
|
||||
addressLng: addressLng,
|
||||
);
|
||||
|
||||
final authState = AuthState.authenticated(
|
||||
token: session.token,
|
||||
userRole: session.userRole,
|
||||
|
||||
@@ -41,6 +41,11 @@ class AuthService {
|
||||
required String email,
|
||||
required String phone,
|
||||
required String password,
|
||||
String? addressCalle,
|
||||
String? addressColonia,
|
||||
String? addressLabel,
|
||||
double? addressLat,
|
||||
double? addressLng,
|
||||
}) {
|
||||
return _authenticate(
|
||||
path: '/auth/register',
|
||||
@@ -48,6 +53,11 @@ class AuthService {
|
||||
'email': email,
|
||||
'phone': phone,
|
||||
'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/foundation.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/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 {
|
||||
const NewAddressPage({super.key});
|
||||
@@ -15,40 +34,168 @@ class NewAddressPage extends ConsumerStatefulWidget {
|
||||
class _NewAddressPageState extends ConsumerState<NewAddressPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _labelController = TextEditingController();
|
||||
final _cpCtrl = TextEditingController();
|
||||
final _streetController = TextEditingController();
|
||||
Colonia? _selectedColonia;
|
||||
String _tipoInmueble = 'Casa';
|
||||
|
||||
final _mapController = MapController();
|
||||
LatLng? _selectedLocation;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_labelController.dispose();
|
||||
_cpCtrl.dispose();
|
||||
_streetController.dispose();
|
||||
_mapController.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)) {
|
||||
return;
|
||||
}
|
||||
if (_selectedColonia == null) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Selecciona una colonia')));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Ingresa un código postal válido')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final address = AddressCreateRequest(
|
||||
label: _labelController.text.trim(),
|
||||
street: _streetController.text.trim(),
|
||||
colonia: _selectedColonia!.nombre,
|
||||
);
|
||||
try {
|
||||
const storage = FlutterSecureStorage();
|
||||
final token = await storage.read(key: authTokenStorageKey) ?? '';
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Domicilio listo: ${address.toJson()}')),
|
||||
);
|
||||
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': _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
|
||||
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(
|
||||
appBar: AppBar(title: const Text('Nuevo domicilio')),
|
||||
body: SafeArea(
|
||||
@@ -60,37 +207,214 @@ class _NewAddressPageState extends ConsumerState<NewAddressPage> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextFormField(
|
||||
AppFormField(
|
||||
label: 'Etiqueta',
|
||||
hint: 'Ej. Casa de mis padres, Oficina...',
|
||||
controller: _labelController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Etiqueta',
|
||||
hintText: 'Casa, trabajo, etc.',
|
||||
),
|
||||
validator: (value) =>
|
||||
(value == null || value.trim().isEmpty)
|
||||
? 'Ingresa una etiqueta'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _streetController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Calle',
|
||||
hintText: 'Av. Principal 123',
|
||||
const Text(
|
||||
'Selección de domicilio',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
validator: (value) =>
|
||||
(value == null || value.trim().isEmpty)
|
||||
? 'Ingresa la calle'
|
||||
: null,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
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),
|
||||
ColoniasSelector(
|
||||
labelText: 'Colonia',
|
||||
initialValue: _selectedColonia,
|
||||
onChanged: (colonia) {
|
||||
setState(() => _selectedColonia = colonia);
|
||||
},
|
||||
AppFormField(
|
||||
label: 'Código Postal',
|
||||
hint: 'Ej. 38000',
|
||||
controller: _cpCtrl,
|
||||
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),
|
||||
SizedBox(
|
||||
height: 52,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
@@ -11,6 +11,8 @@ import '../../core/theme/app_theme.dart';
|
||||
import '../../core/widgets/app_widgets.dart';
|
||||
import '../../core/services/auth_controller.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 '../home/colonias_data.dart';
|
||||
import '../addresses/colonias_provider.dart';
|
||||
@@ -199,34 +201,6 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
||||
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 {
|
||||
if (_calleCtrl.text.trim().isEmpty || _selectedColonia == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -238,32 +212,65 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
||||
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 colonia = _selectedColonia!.nombre;
|
||||
final lat = _selectedLocation?.latitude;
|
||||
final lng = _selectedLocation?.longitude;
|
||||
|
||||
// 1. Registra al usuario
|
||||
await ref
|
||||
.read(authControllerProvider.notifier)
|
||||
.register(
|
||||
email: _emailCtrl.text.trim(),
|
||||
phone: _telefonoCtrl.text.trim(),
|
||||
password: _passCtrl.text,
|
||||
);
|
||||
try {
|
||||
await ref
|
||||
.read(authControllerProvider.notifier)
|
||||
.register(
|
||||
email: _emailCtrl.text.trim(),
|
||||
phone: phone,
|
||||
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.
|
||||
if (!mounted) {
|
||||
_postAddressInBackground(calle, colonia);
|
||||
return;
|
||||
// Guardado silencioso de la dirección tras un registro exitoso
|
||||
_postAddressInBackground(calle, colonia, lat, lng);
|
||||
} catch (_) {
|
||||
// El error ya es manejado por el listener y muestra el SnackBar
|
||||
}
|
||||
}
|
||||
|
||||
// Si seguimos aquí, verificar si hubo un error (ej. contraseña corta)
|
||||
if (ref.read(authControllerProvider).hasError) return;
|
||||
Future<void> _postAddressInBackground(
|
||||
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
|
||||
await _postAddressInBackground(calle, colonia);
|
||||
if (mounted) {
|
||||
context.go('/home');
|
||||
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 crear la dirección: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,12 +407,7 @@ class _Step1 extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
AppFormField(
|
||||
label: 'Teléfono',
|
||||
hint: '+52 461 123 4567',
|
||||
controller: telefonoCtrl,
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
_PhoneField(controller: telefonoCtrl),
|
||||
const SizedBox(height: 14),
|
||||
AppFormField(
|
||||
label: 'Contraseña',
|
||||
@@ -523,14 +525,6 @@ class _Step2 extends StatelessWidget {
|
||||
|
||||
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(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
@@ -611,11 +605,13 @@ class _Step2 extends StatelessWidget {
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Colonia: ${selectedColonia!.nombre}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.primaryDark,
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Colonia: ${selectedColonia!.nombre}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.primaryDark,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -668,9 +664,6 @@ class _Step2 extends StatelessWidget {
|
||||
options: MapOptions(
|
||||
initialCenter: mapCenter,
|
||||
initialZoom: 15.0,
|
||||
cameraConstraint: bounds != null
|
||||
? CameraConstraint.containCenter(bounds: bounds)
|
||||
: const CameraConstraint.unconstrained(),
|
||||
onTap: (_, latlng) => onLocationChanged(latlng),
|
||||
),
|
||||
children: [
|
||||
@@ -807,7 +800,10 @@ class _Step2 extends StatelessWidget {
|
||||
children: [
|
||||
Icon(Icons.check, size: 18),
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
class _RadioOption extends StatelessWidget {
|
||||
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:latlong2/latlong.dart';
|
||||
|
||||
import '../../core/constants/auth_constants.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/models/ui_models.dart';
|
||||
import 'colonias_data.dart';
|
||||
@@ -30,7 +31,7 @@ class _CitizenHomeScreenState extends State<CitizenHomeScreen> {
|
||||
Future<void> _loadData() async {
|
||||
try {
|
||||
const storage = FlutterSecureStorage();
|
||||
final token = await storage.read(key: 'token') ?? '';
|
||||
final token = await storage.read(key: authTokenStorageKey) ?? '';
|
||||
|
||||
if (token.isEmpty) {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
@@ -53,7 +54,8 @@ class _CitizenHomeScreenState extends State<CitizenHomeScreen> {
|
||||
if (colRes.data is List) {
|
||||
for (var c in colRes.data) {
|
||||
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) {
|
||||
_horarios[nombre] = horario;
|
||||
}
|
||||
@@ -67,14 +69,19 @@ class _CitizenHomeScreenState extends State<CitizenHomeScreen> {
|
||||
final res = await dio.get('/addresses');
|
||||
List<UIHouseModel> loadedCasas = [];
|
||||
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
|
||||
Map<String, String> loadedEtas = {};
|
||||
for (var casa in loadedCasas) {
|
||||
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';
|
||||
} catch (e) {
|
||||
loadedEtas[casa.id] = 'Calculando...';
|
||||
@@ -110,29 +117,30 @@ class _CitizenHomeScreenState extends State<CitizenHomeScreen> {
|
||||
setState(() => _isLoading = true);
|
||||
_loadData();
|
||||
},
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _casas.isEmpty
|
||||
? const Center(
|
||||
child: Text(
|
||||
'No tienes domicilios registrados.',
|
||||
style: TextStyle(color: AppTheme.textSecondary),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _casas.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 24),
|
||||
itemBuilder: (context, index) {
|
||||
final casa = _casas[index];
|
||||
final eta = _etas[casa.id] ?? 'Actualizando...';
|
||||
final horario = _horarios[casa.colonia] ?? 'Horario asignado a la ruta';
|
||||
return _HouseEtaCard(casa: casa, etaMsg: eta, horario: horario);
|
||||
},
|
||||
),
|
||||
? const Center(
|
||||
child: Text(
|
||||
'No tienes domicilios registrados.',
|
||||
style: TextStyle(color: AppTheme.textSecondary),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _casas.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 24),
|
||||
itemBuilder: (context, index) {
|
||||
final casa = _casas[index];
|
||||
final eta = _etas[casa.id] ?? 'Actualizando...';
|
||||
final 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
|
||||
Widget build(BuildContext context) {
|
||||
final center = kColoniasCoordinates[casa.colonia] ?? const LatLng(20.5222, -100.8123);
|
||||
|
||||
// Restricción del mapa a la colonia (Privacidad por Diseño)
|
||||
final bounds = LatLngBounds(
|
||||
LatLng(center.latitude - 0.01, center.longitude - 0.01),
|
||||
LatLng(center.latitude + 0.01, center.longitude + 0.01),
|
||||
);
|
||||
// Si el usuario registró coordenadas, las usamos; si no, el centro de la colonia
|
||||
final coloniaCenter = kColoniaCenter(casa.colonia);
|
||||
final pin = (casa.lat != null && casa.lng != null)
|
||||
? LatLng(casa.lat!, casa.lng!)
|
||||
: coloniaCenter;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
@@ -170,15 +176,15 @@ class _HouseEtaCard extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// ── Mapa Restringido ──
|
||||
// ── Mapa Restringido a la colonia ──
|
||||
SizedBox(
|
||||
height: 180,
|
||||
child: FlutterMap(
|
||||
options: MapOptions(
|
||||
initialCameraFit: CameraFit.bounds(bounds: bounds),
|
||||
cameraConstraint: CameraConstraint.contain(bounds: bounds),
|
||||
initialCenter: pin,
|
||||
initialZoom: 16.0,
|
||||
interactionOptions: const InteractionOptions(
|
||||
flags: InteractiveFlag.drag | InteractiveFlag.pinchZoom,
|
||||
flags: InteractiveFlag.none,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
@@ -186,15 +192,17 @@ class _HouseEtaCard extends StatelessWidget {
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'com.onlineshack.recolecta',
|
||||
),
|
||||
CircleLayer(
|
||||
circles: [
|
||||
CircleMarker(
|
||||
point: center,
|
||||
color: AppTheme.primary.withValues(alpha: 0.15),
|
||||
borderColor: AppTheme.primary,
|
||||
borderStrokeWidth: 2,
|
||||
radius: 400,
|
||||
useRadiusInMeter: true,
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
Marker(
|
||||
point: pin,
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: const Icon(
|
||||
Icons.home_rounded,
|
||||
color: AppTheme.primary,
|
||||
size: 36,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -223,9 +231,17 @@ class _HouseEtaCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
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),
|
||||
_InfoRow(icon: Icons.schedule_outlined, title: 'Horario Habitual', value: horario),
|
||||
_InfoRow(
|
||||
icon: Icons.schedule_outlined,
|
||||
title: 'Horario Habitual',
|
||||
value: horario,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
// ── Alerta de ETA en Tiempo Real ──
|
||||
@@ -239,7 +255,10 @@ class _HouseEtaCard extends StatelessWidget {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.local_shipping_outlined, color: AppTheme.primaryDark),
|
||||
const Icon(
|
||||
Icons.local_shipping_outlined,
|
||||
color: AppTheme.primaryDark,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -283,7 +302,11 @@ class _InfoRow extends StatelessWidget {
|
||||
final String title;
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
@@ -298,12 +321,20 @@ class _InfoRow extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
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),
|
||||
Text(
|
||||
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';
|
||||
|
||||
// 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 = {
|
||||
'Zona Centro': LatLng(20.52254, -100.81153),
|
||||
'Las Arboledas': LatLng(20.51422, -100.82793),
|
||||
@@ -12,3 +9,12 @@ const Map<String, LatLng> kColoniasCoordinates = {
|
||||
'Las Insurgentes': LatLng(20.52427, -100.79548),
|
||||
'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:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../../core/constants/auth_constants.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/models/ui_models.dart';
|
||||
import 'colonias_data.dart';
|
||||
@@ -28,7 +30,7 @@ class _MyHouseScreenState extends State<MyHouseScreen> {
|
||||
Future<void> _cargarDomicilio() async {
|
||||
try {
|
||||
const storage = FlutterSecureStorage();
|
||||
final token = await storage.read(key: 'token') ?? '';
|
||||
final token = await storage.read(key: authTokenStorageKey) ?? '';
|
||||
|
||||
if (token.isEmpty) {
|
||||
setState(() => _isLoading = false);
|
||||
@@ -96,7 +98,11 @@ class _MyHouseScreenState extends State<MyHouseScreen> {
|
||||
_CasaCard(casa: _casa!),
|
||||
const SizedBox(height: 16),
|
||||
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 AppSectionTitle(title: 'Radio de alerta'),
|
||||
_RadioAlertaCard(
|
||||
@@ -120,13 +126,13 @@ class _MyHouseScreenState extends State<MyHouseScreen> {
|
||||
_HorarioCard(),
|
||||
const SizedBox(height: 16),
|
||||
GestureDetector(
|
||||
onTap: () => ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Funcionalidad próximamente disponible'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: AppTheme.primary,
|
||||
),
|
||||
),
|
||||
onTap: () async {
|
||||
final added = await context.push<bool>('/add-address');
|
||||
if (added == true && mounted) {
|
||||
setState(() => _isLoading = true);
|
||||
_cargarDomicilio();
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
@@ -251,19 +257,14 @@ class _CasaCard extends StatelessWidget {
|
||||
// ── Mapa de Colonia (Restringido para Privacidad) ──────────────────────────────
|
||||
class _MapaColoniaRestringido extends StatelessWidget {
|
||||
final String colonia;
|
||||
const _MapaColoniaRestringido({required this.colonia});
|
||||
final double? lat;
|
||||
final double? lng;
|
||||
const _MapaColoniaRestringido({required this.colonia, this.lat, this.lng});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Usa las coordenadas del archivo centralizado de datos de colonias.
|
||||
final 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),
|
||||
);
|
||||
final center = kColoniaCenter(colonia);
|
||||
final pin = (lat != null && lng != null) ? LatLng(lat!, lng!) : center;
|
||||
|
||||
return Container(
|
||||
height: 200,
|
||||
@@ -274,11 +275,10 @@ class _MapaColoniaRestringido extends StatelessWidget {
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: FlutterMap(
|
||||
options: MapOptions(
|
||||
initialCameraFit: CameraFit.bounds(bounds: bounds),
|
||||
// ESTO ES LA MAGIA DE LA PRIVACIDAD: Bloquea el mapa a esta caja
|
||||
cameraConstraint: CameraConstraint.contain(bounds: bounds),
|
||||
initialCenter: pin,
|
||||
initialZoom: 16.0,
|
||||
interactionOptions: const InteractionOptions(
|
||||
flags: InteractiveFlag.drag | InteractiveFlag.pinchZoom,
|
||||
flags: InteractiveFlag.none,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
@@ -286,15 +286,17 @@ class _MapaColoniaRestringido extends StatelessWidget {
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'com.onlineshack.recolecta',
|
||||
),
|
||||
CircleLayer(
|
||||
circles: [
|
||||
CircleMarker(
|
||||
point: center,
|
||||
color: AppTheme.primary.withValues(alpha: 0.2),
|
||||
borderColor: AppTheme.primary,
|
||||
borderStrokeWidth: 2,
|
||||
radius: 350, // 350 metros a la redonda remarcados
|
||||
useRadiusInMeter: true,
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
Marker(
|
||||
point: pin,
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: const Icon(
|
||||
Icons.home_rounded,
|
||||
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>(
|
||||
future: _loadProfile(storage),
|
||||
builder: (context, snapshot) {
|
||||
final profile = snapshot.data ??
|
||||
final profile =
|
||||
snapshot.data ??
|
||||
_ProfileData(
|
||||
email: authState?.token != null ? '…' : '',
|
||||
role: authState?.userRole ?? 'citizen',
|
||||
@@ -39,7 +40,7 @@ class ProfileScreen extends ConsumerWidget {
|
||||
icon: Icons.person_outline,
|
||||
title: 'Editar perfil',
|
||||
subtitle: profile.email,
|
||||
onTap: () {},
|
||||
onTap: () => context.go('/edit-profile'),
|
||||
),
|
||||
AppMenuTile(
|
||||
icon: Icons.lock_outline,
|
||||
@@ -110,7 +111,10 @@ class ProfileScreen extends ConsumerWidget {
|
||||
'Recolecta v1.0.0\nServicio de Limpia · Celaya, Gto.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: AppTheme.textHint, height: 1.6),
|
||||
fontSize: 12,
|
||||
color: AppTheme.textHint,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
@@ -133,19 +137,26 @@ class ProfileScreen extends ConsumerWidget {
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: AppTheme.surface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusLg)),
|
||||
title: const Text('Cerrar sesión',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppTheme.textPrimary)),
|
||||
content: const Text('¿Estás seguro de que deseas cerrar sesión?',
|
||||
style: TextStyle(fontSize: 14, color: AppTheme.textSecondary)),
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
||||
),
|
||||
title: const Text(
|
||||
'Cerrar sesión',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
content: const Text(
|
||||
'¿Estás seguro de que deseas cerrar sesión?',
|
||||
style: TextStyle(fontSize: 14, color: AppTheme.textSecondary),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppTheme.textSecondary),
|
||||
foregroundColor: AppTheme.textSecondary,
|
||||
),
|
||||
child: const Text('Cancelar'),
|
||||
),
|
||||
TextButton(
|
||||
@@ -155,8 +166,10 @@ class ProfileScreen extends ConsumerWidget {
|
||||
if (context.mounted) context.go('/login');
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: AppTheme.danger),
|
||||
child: const Text('Cerrar sesión',
|
||||
style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
child: const Text(
|
||||
'Cerrar sesión',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -169,13 +182,9 @@ class _ProfileData {
|
||||
final String email;
|
||||
final String role;
|
||||
|
||||
const _ProfileData({
|
||||
this.email = '',
|
||||
this.role = 'citizen',
|
||||
});
|
||||
const _ProfileData({this.email = '', this.role = 'citizen'});
|
||||
|
||||
String get iniciales =>
|
||||
email.isNotEmpty ? email[0].toUpperCase() : 'U';
|
||||
String get iniciales => email.isNotEmpty ? email[0].toUpperCase() : 'U';
|
||||
|
||||
String get displayName => email;
|
||||
bool get isAdmin => role == 'admin';
|
||||
@@ -210,9 +219,10 @@ class _ProfileHeader extends StatelessWidget {
|
||||
child: Text(
|
||||
profile.iniciales,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppTheme.primaryDark),
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppTheme.primaryDark,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -221,18 +231,26 @@ class _ProfileHeader extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(profile.displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppTheme.textPrimary)),
|
||||
Text(
|
||||
profile.displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(profile.email,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textSecondary)),
|
||||
Text(
|
||||
profile.email,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
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