diff --git a/animations/Recogida_correcta.mp4 b/animations/Recogida_correcta.mp4 new file mode 100644 index 0000000..7ae1129 Binary files /dev/null and b/animations/Recogida_correcta.mp4 differ diff --git a/animations/blink crazy1.mp4 b/animations/blink crazy1.mp4 deleted file mode 100644 index 065a06d..0000000 Binary files a/animations/blink crazy1.mp4 and /dev/null differ diff --git a/animations/blink feliz 1.mp4 b/animations/blink feliz 1.mp4 deleted file mode 100644 index ae88390..0000000 Binary files a/animations/blink feliz 1.mp4 and /dev/null differ diff --git a/animations/blink saludo.mp4 b/animations/blink saludo.mp4 deleted file mode 100644 index d0d386b..0000000 Binary files a/animations/blink saludo.mp4 and /dev/null differ diff --git a/animations/condrix.mp4 b/animations/condrix.mp4 deleted file mode 100644 index 26914ca..0000000 Binary files a/animations/condrix.mp4 and /dev/null differ diff --git a/animations/info.mp4 b/animations/info.mp4 new file mode 100644 index 0000000..945f780 Binary files /dev/null and b/animations/info.mp4 differ diff --git a/animations/saludo.mp4 b/animations/saludo.mp4 new file mode 100644 index 0000000..5080c06 Binary files /dev/null and b/animations/saludo.mp4 differ diff --git a/backend/app/api/addresses.py b/backend/app/api/addresses.py index 5a12a98..75e142e 100644 --- a/backend/app/api/addresses.py +++ b/backend/app/api/addresses.py @@ -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() ) diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index cb220b0..1f03712 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -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, ) diff --git a/backend/app/api/users.py b/backend/app/api/users.py new file mode 100644 index 0000000..57534c5 --- /dev/null +++ b/backend/app/api/users.py @@ -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 + diff --git a/backend/app/schemas/addresses.py b/backend/app/schemas/addresses.py index 1d6fae5..02f6763 100644 --- a/backend/app/schemas/addresses.py +++ b/backend/app/schemas/addresses.py @@ -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 diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index cbdbcb4..70c532e 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -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 diff --git a/backend/main.py b/backend/main.py index 1ace17f..dd60b14 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/recolecta_app/.metadata b/recolecta_app/.metadata index 51c0bb5..768732a 100644 --- a/recolecta_app/.metadata +++ b/recolecta_app/.metadata @@ -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 diff --git a/recolecta_app/firebase.json b/recolecta_app/firebase.json index fa1b3d0..58fea0a 100644 --- a/recolecta_app/firebase.json +++ b/recolecta_app/firebase.json @@ -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"}}}}}} \ No newline at end of file +{"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:/ \ No newline at end of file diff --git a/recolecta_app/lib/core/api/api_service.dart b/recolecta_app/lib/core/api/api_service.dart new file mode 100644 index 0000000..0f7304a --- /dev/null +++ b/recolecta_app/lib/core/api/api_service.dart @@ -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((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 updateUser(Map data) async { + try { + await _dio.patch('/users/me', data: data); + } on DioException catch (e) { + throw e.response?.data['detail'] ?? 'Error de red'; + } + } +} diff --git a/recolecta_app/lib/core/models/ui_models.dart b/recolecta_app/lib/core/models/ui_models.dart index e3b659c..8837040 100644 --- a/recolecta_app/lib/core/models/ui_models.dart +++ b/recolecta_app/lib/core/models/ui_models.dart @@ -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(), ); } } diff --git a/recolecta_app/lib/core/router/app_router.dart b/recolecta_app/lib/core/router/app_router.dart index 3fc5b86..31b39ba 100644 --- a/recolecta_app/lib/core/router/app_router.dart +++ b/recolecta_app/lib/core/router/app_router.dart @@ -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((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((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((ref) { path: '/notifications', builder: (context, state) => const NotificationsScreen(), ), - GoRoute( - path: '/quiz', - builder: (context, state) => const QuizScreen(), - ), + GoRoute(path: '/quiz', builder: (context, state) => const QuizScreen()), ], ); }); diff --git a/recolecta_app/lib/core/services/auth_controller.dart b/recolecta_app/lib/core/services/auth_controller.dart index 0a69f81..8902f6e 100644 --- a/recolecta_app/lib/core/services/auth_controller.dart +++ b/recolecta_app/lib/core/services/auth_controller.dart @@ -47,12 +47,25 @@ class AuthController extends AsyncNotifier { required String email, required String phone, required String password, + String? addressCalle, + String? addressColonia, + String? addressLabel, + double? addressLat, + double? addressLng, }) async { state = const AsyncLoading(); 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, diff --git a/recolecta_app/lib/core/services/auth_service.dart b/recolecta_app/lib/core/services/auth_service.dart index 03f52c8..5d48b41 100644 --- a/recolecta_app/lib/core/services/auth_service.dart +++ b/recolecta_app/lib/core/services/auth_service.dart @@ -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, }, ); } diff --git a/recolecta_app/lib/features/addresses/add_address_page.dart b/recolecta_app/lib/features/addresses/add_address_page.dart new file mode 100644 index 0000000..3e5b8c8 --- /dev/null +++ b/recolecta_app/lib/features/addresses/add_address_page.dart @@ -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 _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 createState() => _AddAddressPageState(); +} + +class _AddAddressPageState extends ConsumerState { + 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 _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 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 _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 = { + '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), + ], + ), + ), + ); + } +} diff --git a/recolecta_app/lib/features/addresses/address_map_card.dart b/recolecta_app/lib/features/addresses/address_map_card.dart new file mode 100644 index 0000000..e767b8c --- /dev/null +++ b/recolecta_app/lib/features/addresses/address_map_card.dart @@ -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, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/recolecta_app/lib/features/addresses/new_address_page.dart b/recolecta_app/lib/features/addresses/new_address_page.dart index 4c08f92..cb83173 100644 --- a/recolecta_app/lib/features/addresses/new_address_page.dart +++ b/recolecta_app/lib/features/addresses/new_address_page.dart @@ -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 _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 { final _formKey = GlobalKey(); 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 _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 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 _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 { 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( + 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( + 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, diff --git a/recolecta_app/lib/features/auth/register_page.dart b/recolecta_app/lib/features/auth/register_page.dart index 50bc77c..0f2545b 100644 --- a/recolecta_app/lib/features/auth/register_page.dart +++ b/recolecta_app/lib/features/auth/register_page.dart @@ -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 { FocusScope.of(context).unfocus(); // Cierra el teclado } - Future _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 _register() async { if (_calleCtrl.text.trim().isEmpty || _selectedColonia == null) { ScaffoldMessenger.of(context).showSnackBar( @@ -238,32 +212,65 @@ class _RegisterPageState extends ConsumerState { 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 _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; diff --git a/recolecta_app/lib/features/eta/eta_model.dart b/recolecta_app/lib/features/eta/eta_model.dart new file mode 100644 index 0000000..5ed990f --- /dev/null +++ b/recolecta_app/lib/features/eta/eta_model.dart @@ -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 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; +} \ No newline at end of file diff --git a/recolecta_app/lib/features/home/citizen_home_screen.dart b/recolecta_app/lib/features/home/citizen_home_screen.dart index 22c2ab2..3ac2076 100644 --- a/recolecta_app/lib/features/home/citizen_home_screen.dart +++ b/recolecta_app/lib/features/home/citizen_home_screen.dart @@ -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,8 +31,8 @@ class _CitizenHomeScreenState extends State { Future _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); return; @@ -53,7 +54,8 @@ class _CitizenHomeScreenState extends State { 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 { final res = await dio.get('/addresses'); List 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 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 { 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,22 +192,24 @@ 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, + ), ), ], ), ], ), ), - + // ── Recuadro de Información ── Padding( padding: const EdgeInsets.all(16), @@ -223,11 +231,19 @@ 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 ── Container( padding: const EdgeInsets.all(12), @@ -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( @@ -282,8 +301,12 @@ class _InfoRow extends StatelessWidget { final IconData icon; 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, + ), ), ], ), diff --git a/recolecta_app/lib/features/home/colonias_data.dart b/recolecta_app/lib/features/home/colonias_data.dart index d22cb0c..90f7d44 100644 --- a/recolecta_app/lib/features/home/colonias_data.dart +++ b/recolecta_app/lib/features/home/colonias_data.dart @@ -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 kColoniasCoordinates = { 'Zona Centro': LatLng(20.52254, -100.81153), 'Las Arboledas': LatLng(20.51422, -100.82793), @@ -12,3 +9,12 @@ const Map 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 +} diff --git a/recolecta_app/lib/features/home/house_screen.dart b/recolecta_app/lib/features/home/house_screen.dart index d79a304..af9de52 100644 --- a/recolecta_app/lib/features/home/house_screen.dart +++ b/recolecta_app/lib/features/home/house_screen.dart @@ -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 { Future _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 { _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 { _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('/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, + ), ), ], ), diff --git a/recolecta_app/lib/features/profile/edit_profile_screen.dart b/recolecta_app/lib/features/profile/edit_profile_screen.dart new file mode 100644 index 0000000..c82ec6f --- /dev/null +++ b/recolecta_app/lib/features/profile/edit_profile_screen.dart @@ -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 createState() => + _EditProfileScreenState(); +} + +class _EditProfileScreenState extends ConsumerState { + final _formKey = GlobalKey(); + 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 _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; + }, + ), + ], + ), + ), + ); + } +} diff --git a/recolecta_app/lib/features/profile/profile_screen.dart b/recolecta_app/lib/features/profile/profile_screen.dart index ea944e6..611db4b 100644 --- a/recolecta_app/lib/features/profile/profile_screen.dart +++ b/recolecta_app/lib/features/profile/profile_screen.dart @@ -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', + ), ], ), ), diff --git a/recolecta_app/lib/features/separation_guide/ai_chat_provider.dart b/recolecta_app/lib/features/separation_guide/ai_chat_provider.dart new file mode 100644 index 0000000..b833ea9 --- /dev/null +++ b/recolecta_app/lib/features/separation_guide/ai_chat_provider.dart @@ -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 toJson() => {'role': role, 'content': content}; +} + +class AiChatNotifier extends StateNotifier> { + 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 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>( + (ref) { + return AiChatNotifier(); + }, +); diff --git a/recolecta_app/lib/features/separation_guide/ai_pet_chat_screen.dart b/recolecta_app/lib/features/separation_guide/ai_pet_chat_screen.dart new file mode 100644 index 0000000..b7bde5a --- /dev/null +++ b/recolecta_app/lib/features/separation_guide/ai_pet_chat_screen.dart @@ -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 createState() => _AiPetChatScreenState(); +} + +class _AiPetChatScreenState extends ConsumerState { + 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, + ), + ), + ), + ); + } +} diff --git a/views_v3/admin_screen.dart b/views_v3/admin_screen.dart new file mode 100644 index 0000000..5fad745 --- /dev/null +++ b/views_v3/admin_screen.dart @@ -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 createState() => _AdminScreenState(); +} + +class _AdminScreenState extends State { + int _currentTab = 1; + int _selectedSection = 0; + + final List _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 _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 _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(); + + 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(); + + 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(); + + 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( + 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 item, List 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'), + ), + ], + ), + ], + ), + ); + }, + ); + } +}