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:
shinra32
2026-05-23 03:13:46 -06:00
parent 0279ad05f4
commit 45ffba69b2
33 changed files with 2810 additions and 296 deletions

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

Binary file not shown.

BIN
animations/saludo.mp4 Normal file

Binary file not shown.

View File

@@ -29,10 +29,7 @@ def create_address(
"""Alta de domicilio. El routeId se deriva automáticamente de la colonia elegida."""
route_id = _resolve_route_id(body.colonia)
result = (
supabase_admin.table("addresses")
.insert(
{
insert_data: dict = {
"user_id": current_user["user_id"],
"label": body.label,
"calle": body.calle,
@@ -40,7 +37,14 @@ def create_address(
"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(insert_data)
.execute()
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -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) ?? '';
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(
SnackBar(content: Text('Domicilio listo: ${address.toJson()}')),
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,
),
),
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),
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),
ColoniasSelector(
labelText: 'Colonia',
initialValue: _selectedColonia,
onChanged: (colonia) {
setState(() => _selectedColonia = colonia);
},
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,

View File

@@ -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
try {
await ref
.read(authControllerProvider.notifier)
.register(
email: _emailCtrl.text.trim(),
phone: _telefonoCtrl.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,13 +605,15 @@ class _Step2 extends StatelessWidget {
size: 18,
),
const SizedBox(width: 8),
Text(
Expanded(
child: Text(
'Colonia: ${selectedColonia!.nombre}',
style: const TextStyle(
fontWeight: FontWeight.w600,
color: AppTheme.primaryDark,
),
),
),
],
),
const SizedBox(height: 8),
@@ -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;

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

View File

@@ -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,7 +117,7 @@ class _CitizenHomeScreenState extends State<CitizenHomeScreen> {
setState(() => _isLoading = true);
_loadData();
},
)
),
],
),
body: _isLoading
@@ -129,7 +136,8 @@ class _CitizenHomeScreenState extends State<CitizenHomeScreen> {
itemBuilder: (context, index) {
final casa = _casas[index];
final eta = _etas[casa.id] ?? 'Actualizando...';
final horario = _horarios[casa.colonia] ?? 'Horario asignado a la ruta';
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,
),
),
],
),

View File

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

View File

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

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

View File

@@ -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',
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)),
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';
@@ -212,7 +221,8 @@ class _ProfileHeader extends StatelessWidget {
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppTheme.primaryDark),
color: AppTheme.primaryDark,
),
),
),
),
@@ -221,18 +231,26 @@ class _ProfileHeader extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(profile.displayName,
Text(
profile.displayName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppTheme.textPrimary)),
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 2),
Text(profile.email,
Text(
profile.email,
style: const TextStyle(
fontSize: 13, color: AppTheme.textSecondary)),
fontSize: 13,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 6),
AppStatusBadge.green(
profile.isAdmin ? 'Administrador' : 'Ciudadano'),
profile.isAdmin ? 'Administrador' : 'Ciudadano',
),
],
),
),

View File

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

View File

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