From ca076607c7b8279e7da0e3d0d4a2907fb3ac92a9 Mon Sep 17 00:00:00 2001 From: shinra32 Date: Sat, 23 May 2026 05:03:05 -0600 Subject: [PATCH] vistas de ciudadano, escalar animaciones de mascota, implementacion de chatbot para concientizacion, modificacion de datos de ciudadano, modificacion de vista principal --- backend/app/api/chat.py | 73 ++ backend/app/api/incidents.py | 132 ++ backend/app/api/users.py | 133 +- backend/app/core/config.py | 3 + backend/app/db/rls_policies.sql | 23 + backend/app/db/tables.sql | 19 + backend/app/schemas/incidents.py | 32 + backend/app/schemas/users.py | 21 + backend/main.py | 4 + backend/requirements.txt | 2 + recolecta_app/lib/core/router/app_router.dart | 12 + .../features/about/screens/about_screen.dart | 109 ++ .../lib/features/auth/login_page.dart | 34 +- .../lib/features/eta/eta_screen.dart | 14 +- .../lib/features/feedback/feedback_model.dart | 39 + .../features/feedback/feedback_provider.dart | 0 .../features/help/data/help_chat_service.dart | 21 + .../help/providers/help_chat_provider.dart | 83 ++ .../help/screens/help_faq_screen.dart | 225 ++++ .../features/home/citizen_home_screen.dart | 1083 ++++++++++++----- .../lib/features/home/citizen_shell.dart | 2 + .../lib/features/home/main_shell.dart | 4 +- .../incidents/data/incident_service.dart | 67 + .../features/incidents/models/incident.dart | 56 + .../providers/incident_providers.dart | 12 + .../screens/report_issue_screen.dart | 244 ++++ .../profile/data/profile_service.dart | 38 + .../features/profile/edit_profile_screen.dart | 460 +++++-- .../features/profile/models/profile_user.dart | 43 + .../lib/features/profile/profile_screen.dart | 250 ++-- .../profile/providers/profile_providers.dart | 8 + .../shared/widgets/eco_floating_button.dart | 76 ++ .../flutter/generated_plugin_registrant.cc | 4 + .../linux/flutter/generated_plugins.cmake | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 4 + recolecta_app/pubspec.lock | 130 +- recolecta_app/pubspec.yaml | 4 + .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 1 + 39 files changed, 2909 insertions(+), 560 deletions(-) create mode 100644 backend/app/api/chat.py create mode 100644 backend/app/api/incidents.py create mode 100644 backend/app/schemas/incidents.py create mode 100644 backend/app/schemas/users.py create mode 100644 recolecta_app/lib/features/about/screens/about_screen.dart create mode 100644 recolecta_app/lib/features/feedback/feedback_model.dart create mode 100644 recolecta_app/lib/features/feedback/feedback_provider.dart create mode 100644 recolecta_app/lib/features/help/data/help_chat_service.dart create mode 100644 recolecta_app/lib/features/help/providers/help_chat_provider.dart create mode 100644 recolecta_app/lib/features/help/screens/help_faq_screen.dart create mode 100644 recolecta_app/lib/features/incidents/data/incident_service.dart create mode 100644 recolecta_app/lib/features/incidents/models/incident.dart create mode 100644 recolecta_app/lib/features/incidents/providers/incident_providers.dart create mode 100644 recolecta_app/lib/features/incidents/screens/report_issue_screen.dart create mode 100644 recolecta_app/lib/features/profile/data/profile_service.dart create mode 100644 recolecta_app/lib/features/profile/models/profile_user.dart create mode 100644 recolecta_app/lib/features/profile/providers/profile_providers.dart create mode 100644 recolecta_app/lib/shared/widgets/eco_floating_button.dart diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py new file mode 100644 index 0000000..5f48935 --- /dev/null +++ b/backend/app/api/chat.py @@ -0,0 +1,73 @@ +"""Chatbot de ayuda y FAQ — usa OpenAI desde el backend.""" +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from app.core.config import settings +from app.core.deps import get_current_user + +router = APIRouter(prefix="/chat", tags=["chat"]) + + +SYSTEM_PROMPT = ( + "Eres Eco, el asistente oficial de la app Recolecta, dedicada a la recolección " + "inteligente y privada de residuos sólidos urbanos. Tu objetivo es responder " + "preguntas frecuentes de los ciudadanos sobre: horarios y rutas de los camiones, " + "separación correcta de residuos (orgánico, inorgánico reciclable, sanitario, " + "RAEE, manejo especial), qué hacer si el camión no pasó, cómo reportar un " + "problema y cómo usar la app. " + "Reglas: 1) responde siempre en español neutro y de manera breve (máximo 4 " + "oraciones); 2) si te preguntan algo fuera de este dominio, redirige amablemente " + "al tema; 3) nunca inventes horarios ni rutas específicas — si el usuario pide " + "datos concretos de su colonia, indícale que los puede ver en la pantalla de mapa " + "o ETA dentro de la app; 4) jamás compartas información personal de choferes." +) + + +class ChatMessage(BaseModel): + role: str = Field(pattern=r"^(user|assistant)$") + content: str = Field(min_length=1, max_length=2000) + + +class ChatHelpIn(BaseModel): + messages: list[ChatMessage] = Field(min_length=1, max_length=20) + + +class ChatHelpOut(BaseModel): + reply: str + + +@router.post("/help", response_model=ChatHelpOut) +def chat_help( + body: ChatHelpIn, + _user: dict = Depends(get_current_user), +): + if not settings.OPENAI_API_KEY: + raise HTTPException( + status_code=503, + detail="El asistente no está configurado (falta OPENAI_API_KEY).", + ) + + try: + from openai import OpenAI + except ImportError: + raise HTTPException( + status_code=500, detail="Librería openai no instalada en el servidor." + ) + + client = OpenAI(api_key=settings.OPENAI_API_KEY) + payload = [{"role": "system", "content": SYSTEM_PROMPT}] + [ + m.model_dump() for m in body.messages + ] + + try: + completion = client.chat.completions.create( + model=settings.OPENAI_MODEL, + messages=payload, + temperature=0.4, + max_tokens=400, + ) + reply = completion.choices[0].message.content or "" + except Exception as e: + raise HTTPException(status_code=502, detail=f"Error con OpenAI: {e}") + + return ChatHelpOut(reply=reply.strip()) diff --git a/backend/app/api/incidents.py b/backend/app/api/incidents.py new file mode 100644 index 0000000..c778c1b --- /dev/null +++ b/backend/app/api/incidents.py @@ -0,0 +1,132 @@ +"""Reportes ciudadanos sobre unidades (incidents).""" +import uuid +from typing import Optional + +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile + +from app.core.deps import get_current_user +from app.core.supabase_client import supabase_admin +from app.schemas.incidents import ( + IncidentOut, + UnitPublic, +) + +router = APIRouter(prefix="/incidents", tags=["incidents"]) + +BUCKET = "incident-photos" +VALID_CATEGORIES = {"derrame", "dano_propiedad", "conducta", "no_recoleccion", "otro"} + + +@router.get("/units", response_model=list[UnitPublic]) +def list_units(_user: dict = Depends(get_current_user)): + """Lista unidades activas para que el ciudadano elija al reportar.""" + try: + res = ( + supabase_admin.table("units") + .select("id, plate, status") + .eq("status", "active") + .order("id") + .execute() + ) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error al listar unidades: {e}") + + return [UnitPublic(**r) for r in (res.data or [])] + + +def _upload_photo(user_id: str, photo: UploadFile) -> str: + ext = (photo.filename or "").rsplit(".", 1)[-1].lower() or "jpg" + if ext not in {"jpg", "jpeg", "png", "webp"}: + ext = "jpg" + path = f"{user_id}/{uuid.uuid4().hex}.{ext}" + + content = photo.file.read() + content_type = photo.content_type or f"image/{ext}" + + try: + supabase_admin.storage.from_(BUCKET).upload( + path=path, + file=content, + file_options={"content-type": content_type, "upsert": "false"}, + ) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error al subir foto: {e}") + + try: + public = supabase_admin.storage.from_(BUCKET).get_public_url(path) + return public if isinstance(public, str) else str(public) + except Exception: + return path + + +@router.post("", response_model=IncidentOut, status_code=201) +async def create_incident( + category: str = Form(...), + description: str = Form(...), + unit_id: Optional[int] = Form(None), + photo: Optional[UploadFile] = File(None), + current_user: dict = Depends(get_current_user), +): + if category not in VALID_CATEGORIES: + raise HTTPException(status_code=400, detail="Categoría inválida") + if len(description.strip()) < 3: + raise HTTPException(status_code=400, detail="Descripción demasiado corta") + + user_id = current_user["user_id"] + photo_url: Optional[str] = None + if photo is not None and photo.filename: + photo_url = _upload_photo(user_id, photo) + + payload = { + "user_id": user_id, + "unit_id": unit_id, + "category": category, + "description": description.strip(), + "photo_url": photo_url, + } + try: + res = supabase_admin.table("incidents").insert(payload).execute() + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error al guardar reporte: {e}") + + row = (res.data or [{}])[0] + return IncidentOut( + id=row.get("id"), + user_id=row.get("user_id"), + unit_id=row.get("unit_id"), + category=row.get("category"), + description=row.get("description"), + photo_url=row.get("photo_url"), + status=row.get("status") or "open", + created_at=row.get("created_at") and str(row["created_at"]), + ) + + +@router.get("/me", response_model=list[IncidentOut]) +def my_incidents(current_user: dict = Depends(get_current_user)): + try: + res = ( + supabase_admin.table("incidents") + .select("*") + .eq("user_id", current_user["user_id"]) + .order("created_at", desc=True) + .execute() + ) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error al listar reportes: {e}") + + out: list[IncidentOut] = [] + for row in res.data or []: + out.append( + IncidentOut( + id=row.get("id"), + user_id=row.get("user_id"), + unit_id=row.get("unit_id"), + category=row.get("category"), + description=row.get("description"), + photo_url=row.get("photo_url"), + status=row.get("status") or "open", + created_at=row.get("created_at") and str(row["created_at"]), + ) + ) + return out diff --git a/backend/app/api/users.py b/backend/app/api/users.py index 57534c5..755ccf9 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -1,43 +1,126 @@ -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, EmailStr +from fastapi import APIRouter, Depends, HTTPException, status + from app.core.deps import get_current_user -from app.core.supabase_client import supabase_admin -from gotrue.types import User +from app.core.supabase_client import supabase, supabase_admin +from app.schemas.users import UserMe, UserUpdateMe, ChangePasswordIn router = APIRouter(prefix="/users", tags=["users"]) -class UserUpdate(BaseModel): - name: str | None = None - email: EmailStr | None = None + +@router.get("/me", response_model=UserMe) +def get_me(current_user: dict = Depends(get_current_user)): + """Devuelve el perfil del usuario autenticado (datos públicos + email/phone de auth).""" + user_id = current_user["user_id"] + + try: + public_row = ( + supabase_admin.table("users") + .select("id, name, role, created_at") + .eq("id", user_id) + .maybe_single() + .execute() + ) + public_data = public_row.data or {} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error al leer perfil: {e}") + + email = current_user.get("email") + phone = None + try: + resp = supabase_admin.auth.admin.get_user_by_id(user_id) + auth_user = getattr(resp, "user", None) or resp + email = getattr(auth_user, "email", email) + phone = getattr(auth_user, "phone", None) + except Exception: + pass + + return UserMe( + id=user_id, + email=email, + phone=phone, + name=public_data.get("name"), + role=public_data.get("role") or current_user["role"], + created_at=(public_data.get("created_at") and str(public_data["created_at"])) + or None, + ) + @router.patch("/me", status_code=204) -def update_user_profile( - update_data: UserUpdate, - current_user: User = Depends(get_current_user), +def update_me( + body: UserUpdateMe, + current_user: dict = Depends(get_current_user), ): - """ - Actualiza el perfil del usuario autenticado (nombre, email). - """ - user_id = current_user.id - update_payload = {} + """Actualiza nombre / email / teléfono del usuario autenticado.""" + user_id = current_user["user_id"] - if update_data.name: - # El nombre no está en Supabase Auth, sino en nuestra tabla `users` + if body.name is not None: try: - supabase_admin.table("users").update({"name": update_data.name}).eq( + supabase_admin.table("users").update({"name": body.name}).eq( "id", user_id ).execute() except Exception as e: - raise HTTPException(status_code=500, detail=f"Error al actualizar el nombre: {e}") + raise HTTPException(status_code=500, detail=f"Error al actualizar nombre: {e}") - if update_data.email: - # El email sí está en Supabase Auth + auth_payload: dict = {} + if body.email is not None: + auth_payload["email"] = str(body.email) + if body.phone is not None: + auth_payload["phone"] = body.phone + + if auth_payload: try: - supabase_admin.auth.admin.update_user_by_id( - user_id, {"email": update_data.email} - ) + supabase_admin.auth.admin.update_user_by_id(user_id, auth_payload) except Exception as e: - raise HTTPException(status_code=500, detail=f"Error al actualizar el email: {e}") + raise HTTPException( + status_code=500, detail=f"Error al actualizar credenciales: {e}" + ) + mirror: dict = {} + if "email" in auth_payload: + mirror["email"] = auth_payload["email"] + if "phone" in auth_payload: + mirror["phone"] = auth_payload["phone"] + if mirror: + try: + supabase_admin.table("users").update(mirror).eq("id", user_id).execute() + except Exception: + pass + + return + + +@router.post("/me/change-password", status_code=204) +def change_password( + body: ChangePasswordIn, + current_user: dict = Depends(get_current_user), +): + """Cambia la contraseña verificando primero la actual con signInWithPassword.""" + if body.current_password == body.new_password: + raise HTTPException( + status_code=400, detail="La nueva contraseña debe ser distinta de la actual" + ) + + email = current_user.get("email") + if not email: + raise HTTPException(status_code=400, detail="Usuario sin email asociado") + + try: + supabase.auth.sign_in_with_password( + {"email": email, "password": body.current_password} + ) + except Exception: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Contraseña actual incorrecta", + ) + + try: + supabase_admin.auth.admin.update_user_by_id( + current_user["user_id"], {"password": body.new_password} + ) + except Exception as e: + raise HTTPException( + status_code=500, detail=f"Error al actualizar contraseña: {e}" + ) return diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 9807d23..9715b8b 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -12,6 +12,9 @@ class Settings(BaseSettings): FIREBASE_CREDENTIALS_PATH: str = "app/data/secrets/recoleccion-app-firebase-adminsdk-fbsvc-3da79d2a4c.json" SIMULATION_TICK_SECONDS: int = 10 + OPENAI_API_KEY: str | None = None + OPENAI_MODEL: str = "gpt-4o-mini" + @lru_cache def get_settings() -> Settings: diff --git a/backend/app/db/rls_policies.sql b/backend/app/db/rls_policies.sql index e6f4662..dfe770b 100644 --- a/backend/app/db/rls_policies.sql +++ b/backend/app/db/rls_policies.sql @@ -137,6 +137,29 @@ CREATE POLICY "feedback_insert" ON public.feedback FOR INSERT WITH CHECK (auth.uid() = user_id); +-- ------------------------------------------------------------ +-- 5b. Tabla public.incidents (reportes ciudadanos) +-- ------------------------------------------------------------ +ALTER TABLE public.incidents ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "incidents_select" ON public.incidents; +CREATE POLICY "incidents_select" ON public.incidents + FOR SELECT USING ( + auth.uid() = user_id + OR (SELECT role FROM public.users WHERE id = auth.uid()) = 'admin' + ); + +DROP POLICY IF EXISTS "incidents_insert" ON public.incidents; +CREATE POLICY "incidents_insert" ON public.incidents + FOR INSERT WITH CHECK (auth.uid() = user_id); + +DROP POLICY IF EXISTS "incidents_update_admin" ON public.incidents; +CREATE POLICY "incidents_update_admin" ON public.incidents + FOR UPDATE USING ( + (SELECT role FROM public.users WHERE id = auth.uid()) = 'admin' + ); + + -- ------------------------------------------------------------ -- 6. Índices útiles para rendimiento -- ------------------------------------------------------------ diff --git a/backend/app/db/tables.sql b/backend/app/db/tables.sql index 94c97f2..617545b 100644 --- a/backend/app/db/tables.sql +++ b/backend/app/db/tables.sql @@ -91,3 +91,22 @@ CREATE INDEX IF NOT EXISTS idx_route_positions_route ON public.route_positions(r CREATE INDEX IF NOT EXISTS idx_colonias_route ON public.colonias(route_id); CREATE INDEX IF NOT EXISTS idx_drivers_user ON public.drivers(user_id); CREATE INDEX IF NOT EXISTS idx_collection_route ON public.collection_events(route_id); + +-- ------------------------------------------------------------ +-- 8. INCIDENTS (reportes de ciudadanos sobre unidades) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS public.incidents ( + id BIGSERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + unit_id INT REFERENCES public.units(id), + category TEXT NOT NULL + CHECK (category IN ('derrame','dano_propiedad','conducta','no_recoleccion','otro')), + description TEXT NOT NULL, + photo_url TEXT, + status TEXT NOT NULL DEFAULT 'open' + CHECK (status IN ('open','in_review','resolved')), + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_incidents_user ON public.incidents(user_id); +CREATE INDEX IF NOT EXISTS idx_incidents_unit ON public.incidents(unit_id); diff --git a/backend/app/schemas/incidents.py b/backend/app/schemas/incidents.py new file mode 100644 index 0000000..63c3fd1 --- /dev/null +++ b/backend/app/schemas/incidents.py @@ -0,0 +1,32 @@ +from typing import Literal, Optional + +from pydantic import BaseModel, Field + +IncidentCategory = Literal[ + "derrame", "dano_propiedad", "conducta", "no_recoleccion", "otro" +] +IncidentStatus = Literal["open", "in_review", "resolved"] + + +class UnitPublic(BaseModel): + id: int + plate: Optional[str] = None + status: Optional[str] = None + + +class IncidentOut(BaseModel): + id: int + user_id: str + unit_id: Optional[int] = None + category: IncidentCategory + description: str + photo_url: Optional[str] = None + status: IncidentStatus + created_at: Optional[str] = None + + +class IncidentCreate(BaseModel): + """Payload usado cuando NO se sube foto (JSON).""" + unit_id: Optional[int] = None + category: IncidentCategory + description: str = Field(min_length=3, max_length=1000) diff --git a/backend/app/schemas/users.py b/backend/app/schemas/users.py new file mode 100644 index 0000000..50f2941 --- /dev/null +++ b/backend/app/schemas/users.py @@ -0,0 +1,21 @@ +from pydantic import BaseModel, EmailStr, Field + + +class UserMe(BaseModel): + id: str + email: str | None = None + phone: str | None = None + name: str | None = None + role: str + created_at: str | None = None + + +class UserUpdateMe(BaseModel): + name: str | None = None + email: EmailStr | None = None + phone: str | None = None + + +class ChangePasswordIn(BaseModel): + current_password: str = Field(min_length=6) + new_password: str = Field(min_length=6) diff --git a/backend/main.py b/backend/main.py index de093ce..316643d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -11,6 +11,8 @@ from app.api.colonias import router as colonias_router from app.api.users import router as users_router from app.api.admin import router as admin_router from app.api.simulation import router as simulation_router +from app.api.chat import router as chat_router +from app.api.incidents import router as incidents_router from app.services import simulation, notifications scheduler = AsyncIOScheduler() @@ -63,3 +65,5 @@ app.include_router(colonias_router) app.include_router(users_router) app.include_router(admin_router) app.include_router(simulation_router) +app.include_router(chat_router) +app.include_router(incidents_router) diff --git a/backend/requirements.txt b/backend/requirements.txt index ccac716..978f7fa 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,3 +9,5 @@ sqlalchemy==2.0.30 psycopg2-binary==2.9.9 python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 +openai>=1.40.0 +httpx>=0.27.0 diff --git a/recolecta_app/lib/core/router/app_router.dart b/recolecta_app/lib/core/router/app_router.dart index f62b8c6..a95e9e5 100644 --- a/recolecta_app/lib/core/router/app_router.dart +++ b/recolecta_app/lib/core/router/app_router.dart @@ -22,6 +22,9 @@ import 'package:recolecta_app/core/services/auth_controller.dart'; import '../../features/addresses/add_address_page.dart'; import '../../features/notifications/notifications_screen.dart'; import '../../features/quiz/quiz_screen.dart'; +import '../../features/help/screens/help_faq_screen.dart'; +import '../../features/incidents/screens/report_issue_screen.dart'; +import '../../features/about/screens/about_screen.dart'; final routerProvider = Provider((ref) { final authState = ref.watch(authControllerProvider); @@ -143,6 +146,15 @@ final routerProvider = Provider((ref) { builder: (context, state) => const NotificationsScreen(), ), GoRoute(path: '/quiz', builder: (context, state) => const QuizScreen()), + GoRoute( + path: '/help', + builder: (context, state) => const HelpFaqScreen(), + ), + GoRoute( + path: '/report-issue', + builder: (context, state) => const ReportIssueScreen(), + ), + GoRoute(path: '/about', builder: (context, state) => const AboutScreen()), ], ); }); diff --git a/recolecta_app/lib/features/about/screens/about_screen.dart b/recolecta_app/lib/features/about/screens/about_screen.dart new file mode 100644 index 0000000..39944b8 --- /dev/null +++ b/recolecta_app/lib/features/about/screens/about_screen.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import '../../../core/theme/app_theme.dart'; +import '../../../core/widgets/app_widgets.dart'; + +class AboutScreen extends StatelessWidget { + const AboutScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.background, + appBar: AppBar(title: const Text('Acerca de la app')), + body: FutureBuilder( + future: PackageInfo.fromPlatform(), + builder: (context, snap) { + final version = snap.data?.version ?? '1.0.0'; + final build = snap.data?.buildNumber ?? '1'; + return ListView( + padding: const EdgeInsets.all(16), + children: [ + AppCard( + child: Column( + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: AppTheme.primaryLight, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.recycling_rounded, + size: 40, + color: AppTheme.primaryDark, + ), + ), + const SizedBox(height: 12), + const Text( + 'Recolecta', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + 'Versión $version (build $build)', + style: const TextStyle( + fontSize: 13, + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + AppSectionTitle(title: 'Acerca de'), + Text( + 'Recolecta es una aplicación del Servicio de Limpia de Celaya ' + 'para informar al ciudadano sobre rutas, horarios y separación ' + 'correcta de residuos.', + style: TextStyle( + fontSize: 14, + height: 1.5, + color: AppTheme.textPrimary, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + AppSectionTitle(title: 'Créditos'), + Text( + 'Desarrollado por el equipo ONLINCESHACK.\n' + 'Servicio de Limpia · Celaya, Gto.', + style: TextStyle( + fontSize: 14, + height: 1.5, + color: AppTheme.textPrimary, + ), + ), + ], + ), + ), + const SizedBox(height: 24), + const Center( + child: Text( + '© 2025 Recolecta', + style: TextStyle(fontSize: 12, color: AppTheme.textHint), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/recolecta_app/lib/features/auth/login_page.dart b/recolecta_app/lib/features/auth/login_page.dart index 408b8fd..3f1a723 100644 --- a/recolecta_app/lib/features/auth/login_page.dart +++ b/recolecta_app/lib/features/auth/login_page.dart @@ -141,21 +141,33 @@ class _LoginPageState extends ConsumerState { child: AnimatedSwitcher( duration: const Duration(milliseconds: 200), child: loading - ? const SizedBox( + ? const FittedBox( key: ValueKey('loading'), - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, + fit: BoxFit.scaleDown, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + VideoMascot(size: 34, zoom: 1.5), + SizedBox(width: 12), + Text( + 'Saludando a Eco...', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], ), ) - : const Text( - 'Ingresar', + : const FittedBox( key: ValueKey('text'), - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, + fit: BoxFit.scaleDown, + child: Text( + 'Ingresar', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), ), ), ), diff --git a/recolecta_app/lib/features/eta/eta_screen.dart b/recolecta_app/lib/features/eta/eta_screen.dart index 34bb2e8..56ac2dd 100644 --- a/recolecta_app/lib/features/eta/eta_screen.dart +++ b/recolecta_app/lib/features/eta/eta_screen.dart @@ -9,6 +9,7 @@ // 5. FCM badge // 6. Horario semanal +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -87,11 +88,20 @@ class _EtaResult { // Provider de ETA // ───────────────────────────────────────────────────────────────────────────── class _EtaNotifier extends AsyncNotifier<_EtaResult> { + Timer? _timer; + @override - Future<_EtaResult> build() => _fetch(); + Future<_EtaResult> build() { + // Polling silencioso cada 10 segundos para ver la simulación en vivo + _timer?.cancel(); + _timer = Timer.periodic(const Duration(seconds: 10), (_) => refresh()); + ref.onDispose(() => _timer?.cancel()); + + return _fetch(); + } Future refresh() async { - state = const AsyncValue.loading(); + // Eliminamos el estado "loading" explícito para evitar que la UI parpadee state = await AsyncValue.guard(_fetch); } diff --git a/recolecta_app/lib/features/feedback/feedback_model.dart b/recolecta_app/lib/features/feedback/feedback_model.dart new file mode 100644 index 0000000..e0ee2f5 --- /dev/null +++ b/recolecta_app/lib/features/feedback/feedback_model.dart @@ -0,0 +1,39 @@ +// lib/features/feedback/feedback_model.dart +// La queja solo registra target_unit_id (número de unidad), NUNCA el chofer. + +enum FeedbackType { + noPaso('no_paso', 'No pasó el camión'), + llegoTarde('llego_tarde', 'Llegó tarde'), + comportamiento('comportamiento', 'Comportamiento'), + otro('otro', 'Otro'); + + final String value; + final String label; + const FeedbackType(this.value, this.label); +} + +class FeedbackRequest { + final String addressId; + final FeedbackType type; + final int rating; // 1-5 + final String? message; + /// Solo el número de unidad — nunca el ID del chofer. + final String targetUnitId; + + const FeedbackRequest({ + required this.addressId, + required this.type, + required this.rating, + required this.targetUnitId, + this.message, + }); + + Map toJson() => { + 'address_id': addressId, + 'type': type.value, + 'rating': rating, + 'target_unit_id': targetUnitId, // ej. "101" + if (message != null && message!.isNotEmpty) 'message': message, + // ⚠️ NUNCA se manda: driver_id, driver_name, chofer_* + }; +} \ No newline at end of file diff --git a/recolecta_app/lib/features/feedback/feedback_provider.dart b/recolecta_app/lib/features/feedback/feedback_provider.dart new file mode 100644 index 0000000..e69de29 diff --git a/recolecta_app/lib/features/help/data/help_chat_service.dart b/recolecta_app/lib/features/help/data/help_chat_service.dart new file mode 100644 index 0000000..f562a91 --- /dev/null +++ b/recolecta_app/lib/features/help/data/help_chat_service.dart @@ -0,0 +1,21 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/network/api_client.dart'; + +final helpChatServiceProvider = Provider((ref) { + return HelpChatService(ref.read(apiClientProvider)); +}); + +class HelpChatService { + HelpChatService(this._dio); + final Dio _dio; + + Future ask(List> messages) async { + final res = await _dio.post>( + '/chat/help', + data: {'messages': messages}, + ); + return (res.data?['reply'] as String?) ?? ''; + } +} diff --git a/recolecta_app/lib/features/help/providers/help_chat_provider.dart b/recolecta_app/lib/features/help/providers/help_chat_provider.dart new file mode 100644 index 0000000..d82879e --- /dev/null +++ b/recolecta_app/lib/features/help/providers/help_chat_provider.dart @@ -0,0 +1,83 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/help_chat_service.dart'; + +class HelpChatMessage { + final String role; // 'user' | 'assistant' + final String content; + const HelpChatMessage({required this.role, required this.content}); + + Map toJson() => {'role': role, 'content': content}; +} + +class HelpChatState { + final List messages; + final bool sending; + final String? error; + + const HelpChatState({ + this.messages = const [], + this.sending = false, + this.error, + }); + + HelpChatState copyWith({ + List? messages, + bool? sending, + String? error, + bool clearError = false, + }) => HelpChatState( + messages: messages ?? this.messages, + sending: sending ?? this.sending, + error: clearError ? null : (error ?? this.error), + ); +} + +class HelpChatController extends Notifier { + @override + HelpChatState build() => const HelpChatState(); + + Future send(String text) async { + final trimmed = text.trim(); + if (trimmed.isEmpty || state.sending) return; + + final newMessages = [ + ...state.messages, + HelpChatMessage(role: 'user', content: trimmed), + ]; + state = state.copyWith( + messages: newMessages, + sending: true, + clearError: true, + ); + + try { + final reply = await ref + .read(helpChatServiceProvider) + .ask(newMessages.map((m) => m.toJson()).toList()); + state = state.copyWith( + messages: [ + ...newMessages, + HelpChatMessage(role: 'assistant', content: reply), + ], + sending: false, + ); + } catch (e) { + String msg = 'No se pudo enviar el mensaje'; + if (e is DioException) { + final data = e.response?.data; + if (data is Map && data['detail'] != null) + msg = data['detail'].toString(); + } + state = state.copyWith(sending: false, error: msg); + } + } + + void reset() { + state = const HelpChatState(); + } +} + +final helpChatControllerProvider = + NotifierProvider(HelpChatController.new); diff --git a/recolecta_app/lib/features/help/screens/help_faq_screen.dart b/recolecta_app/lib/features/help/screens/help_faq_screen.dart new file mode 100644 index 0000000..88d6fe9 --- /dev/null +++ b/recolecta_app/lib/features/help/screens/help_faq_screen.dart @@ -0,0 +1,225 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/theme/app_theme.dart'; +import '../providers/help_chat_provider.dart'; + +const _quickQuestions = [ + '¿Cuándo pasa el camión por mi colonia?', + '¿Cómo separo correctamente la basura?', + '¿Qué hago si no pasó el camión?', + '¿Cómo reporto un problema con una unidad?', +]; + +class HelpFaqScreen extends ConsumerStatefulWidget { + const HelpFaqScreen({super.key}); + + @override + ConsumerState createState() => _HelpFaqScreenState(); +} + +class _HelpFaqScreenState extends ConsumerState { + final _inputCtrl = TextEditingController(); + final _scrollCtrl = ScrollController(); + + @override + void dispose() { + _inputCtrl.dispose(); + _scrollCtrl.dispose(); + super.dispose(); + } + + Future _send(String text) async { + _inputCtrl.clear(); + await ref.read(helpChatControllerProvider.notifier).send(text); + await Future.delayed(const Duration(milliseconds: 100)); + if (_scrollCtrl.hasClients) { + _scrollCtrl.animateTo( + _scrollCtrl.position.maxScrollExtent, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + } + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(helpChatControllerProvider); + + return Scaffold( + backgroundColor: AppTheme.background, + appBar: AppBar( + title: const Text('Ayuda y preguntas frecuentes'), + actions: [ + IconButton( + tooltip: 'Reiniciar conversación', + icon: const Icon(Icons.refresh), + onPressed: state.messages.isEmpty + ? null + : () => ref.read(helpChatControllerProvider.notifier).reset(), + ), + ], + ), + body: Column( + children: [ + if (state.messages.isEmpty) _QuickQuestions(onSelect: _send), + Expanded( + child: state.messages.isEmpty + ? const _EmptyHint() + : ListView.builder( + controller: _scrollCtrl, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + itemCount: state.messages.length, + itemBuilder: (_, i) => _Bubble(message: state.messages[i]), + ), + ), + if (state.error != null) + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + color: AppTheme.danger.withOpacity(0.1), + child: Text( + state.error!, + style: const TextStyle(color: AppTheme.danger, fontSize: 13), + ), + ), + _Composer( + controller: _inputCtrl, + sending: state.sending, + onSend: _send, + ), + ], + ), + ); + } +} + +class _QuickQuestions extends StatelessWidget { + final ValueChanged onSelect; + const _QuickQuestions({required this.onSelect}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final q in _quickQuestions) + ActionChip(label: Text(q), onPressed: () => onSelect(q)), + ], + ), + ); + } +} + +class _EmptyHint extends StatelessWidget { + const _EmptyHint(); + + @override + Widget build(BuildContext context) { + return const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text( + 'Hazme una pregunta sobre la recolección de basura,\nseparación de residuos o cómo usar la app.', + textAlign: TextAlign.center, + style: TextStyle(color: AppTheme.textSecondary, height: 1.5), + ), + ), + ); + } +} + +class _Bubble extends StatelessWidget { + final HelpChatMessage message; + const _Bubble({required this.message}); + + @override + Widget build(BuildContext context) { + final isUser = message.role == 'user'; + return Align( + alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.78, + ), + decoration: BoxDecoration( + color: isUser ? AppTheme.primary : AppTheme.surface, + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + border: Border.all( + color: isUser ? AppTheme.primary : AppTheme.border, + width: 0.5, + ), + ), + child: Text( + message.content, + style: TextStyle( + color: isUser ? Colors.white : AppTheme.textPrimary, + fontSize: 14, + height: 1.4, + ), + ), + ), + ); + } +} + +class _Composer extends StatelessWidget { + final TextEditingController controller; + final bool sending; + final ValueChanged onSend; + const _Composer({ + required this.controller, + required this.sending, + required this.onSend, + }); + + @override + Widget build(BuildContext context) { + return SafeArea( + top: false, + child: Container( + padding: const EdgeInsets.fromLTRB(12, 8, 8, 8), + decoration: const BoxDecoration( + color: AppTheme.surface, + border: Border(top: BorderSide(color: AppTheme.border, width: 0.5)), + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: controller, + enabled: !sending, + minLines: 1, + maxLines: 4, + textInputAction: TextInputAction.send, + onSubmitted: onSend, + decoration: const InputDecoration( + hintText: 'Escribe tu pregunta…', + border: InputBorder.none, + ), + ), + ), + IconButton( + icon: sending + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.send_rounded, color: AppTheme.primary), + onPressed: sending ? null : () => onSend(controller.text), + ), + ], + ), + ), + ); + } +} diff --git a/recolecta_app/lib/features/home/citizen_home_screen.dart b/recolecta_app/lib/features/home/citizen_home_screen.dart index 3ac2076..74a77f3 100644 --- a/recolecta_app/lib/features/home/citizen_home_screen.dart +++ b/recolecta_app/lib/features/home/citizen_home_screen.dart @@ -1,345 +1,848 @@ import 'package:flutter/material.dart'; -import 'package:dio/dio.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.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'; +import '../../core/widgets/app_widgets.dart'; +import '../../core/network/api_client.dart'; +import '../notifications/notification_service.dart'; +import '../../shared/widgets/prevention_banner.dart'; +import '../../shared/widgets/progress_steps.dart'; -class CitizenHomeScreen extends StatefulWidget { +// ───────────────────────────────────────────────────────────────────────────── +// Modelo de resultado ETA +// ───────────────────────────────────────────────────────────────────────────── +class _EtaResult { + final String mensaje; + final String status; + final String direccion; + final String colonia; + final bool hasAddress; + final double? lat; + final double? lng; + + const _EtaResult({ + required this.mensaje, + required this.status, + required this.direccion, + required this.colonia, + required this.hasAddress, + this.lat, + this.lng, + }); + + const _EtaResult.noAddress() + : mensaje = '', + status = '', + direccion = '', + colonia = '', + hasAddress = false, + lat = null, + lng = null; + + // ── Utilidades derivadas ─────────────────────────────────────────────────── + + bool get isCompleted => status == 'completada'; + bool get isNearby => + mensaje.contains('15 minutos') || mensaje.contains('Está atendiendo'); + + double get progreso { + if (isNearby) return 0.85; + if (isCompleted) return 1.0; + return 0.35; + } + + /// Índice para el widget ProgressSteps (0 = inicio, 1 = en ruta, 2 = cerca, + /// 3 = atendiendo, 4 = completado). Ajusta los valores según tu enum real. + int get stepIndex { + if (isCompleted) return 4; + if (isNearby) return 3; + if (status == 'en_ruta') return 2; + return 1; + } + + String get etiquetaEstado { + if (isCompleted) return 'Finalizado'; + if (status == 'en_ruta') return 'En ruta'; + return 'Pendiente'; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Provider de ETA +// ───────────────────────────────────────────────────────────────────────────── +class _EtaNotifier extends AsyncNotifier<_EtaResult> { + @override + Future<_EtaResult> build() => _fetch(); + + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(_fetch); + } + + Future<_EtaResult> _fetch() async { + final dio = ref.read(apiClientProvider); + + final addressesResp = await dio.get('/addresses'); + final raw = addressesResp.data; + + List items = const []; + if (raw is List) { + items = raw; + } else if (raw is Map && raw['data'] is List) { + items = raw['data'] as List; + } else if (raw is Map && raw['addresses'] is List) { + items = raw['addresses'] as List; + } + + if (items.isEmpty) return const _EtaResult.noAddress(); + + final addressId = items.first['id'] as String; + final etaResp = await dio.get( + '/eta', + queryParameters: {'address_id': addressId}, + ); + + final data = etaResp.data as Map; + return _EtaResult( + mensaje: data['mensaje'] as String? ?? '', + status: data['status'] as String? ?? '', + direccion: items.first['calle'] as String? ?? '', + colonia: items.first['colonia'] as String? ?? '', + lat: (items.first['lat'] as num?)?.toDouble(), + lng: (items.first['lng'] as num?)?.toDouble(), + hasAddress: true, + ); + } +} + +final etaProvider = AsyncNotifierProvider<_EtaNotifier, _EtaResult>( + _EtaNotifier.new, +); + +// Expone el routeId activo (se puebla desde el provider de sesión/domicilio) +class ActiveRouteIdNotifier extends Notifier { + @override + String? build() => null; +} + +final activeRouteIdProvider = NotifierProvider( + ActiveRouteIdNotifier.new, +); + +// ───────────────────────────────────────────────────────────────────────────── +// Pantalla principal +// ───────────────────────────────────────────────────────────────────────────── +class CitizenHomeScreen extends ConsumerStatefulWidget { const CitizenHomeScreen({super.key}); @override - State createState() => _CitizenHomeScreenState(); + ConsumerState createState() => _CitizenHomeScreenState(); } -class _CitizenHomeScreenState extends State { - bool _isLoading = true; - List _casas = []; - Map _etas = {}; - Map _horarios = {}; - +class _CitizenHomeScreenState extends ConsumerState + with WidgetsBindingObserver { @override void initState() { super.initState(); - _loadData(); + WidgetsBinding.instance.addObserver(this); + // Refresca al recibir push FCM (RUTA_PROXIMITY, ROUTE_START, etc.) + NotificationService.onFcmMessage.addListener(_onPush); } - Future _loadData() async { - try { - const storage = FlutterSecureStorage(); - final token = await storage.read(key: authTokenStorageKey) ?? ''; + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + NotificationService.onFcmMessage.removeListener(_onPush); + super.dispose(); + } - if (token.isEmpty) { - if (mounted) setState(() => _isLoading = false); - return; - } - - final dio = Dio( - BaseOptions( - baseUrl: const String.fromEnvironment( - 'API_BASE_URL', - defaultValue: 'http://localhost:8000', - ), - headers: {'Authorization': 'Bearer $token'}, - ), - ); - - // 1. Obtener horarios de las colonias - try { - final colRes = await dio.get('/colonias'); - 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'; - if (nombre.isNotEmpty) { - _horarios[nombre] = horario; - } - } - } - } catch (_) { - debugPrint('Aviso: No se pudieron cargar los horarios.'); - } - - // 2. Obtener los domicilios del ciudadano - final res = await dio.get('/addresses'); - List loadedCasas = []; - if (res.data is List) { - 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}, - ); - loadedEtas[casa.id] = etaRes.data['mensaje'] ?? 'Estado desconocido'; - } catch (e) { - loadedEtas[casa.id] = 'Calculando...'; - } - } - - if (mounted) { - setState(() { - _casas = loadedCasas; - _etas = loadedEtas; - _isLoading = false; - }); - } - } catch (e) { - debugPrint('Error en CitizenHomeScreen: $e'); - if (mounted) { - setState(() => _isLoading = false); - } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + ref.read(etaProvider.notifier).refresh(); } } + void _onPush() => ref.read(etaProvider.notifier).refresh(); + @override Widget build(BuildContext context) { + final etaAsync = ref.watch(etaProvider); + return Scaffold( backgroundColor: AppTheme.background, appBar: AppBar( - title: const Text('Estado del Servicio'), + title: const Text('Mi recolección'), actions: [ IconButton( - icon: const Icon(Icons.refresh), - tooltip: 'Actualizar tiempos', - onPressed: () { - setState(() => _isLoading = true); - _loadData(); - }, + icon: const Icon(Icons.refresh_rounded), + tooltip: 'Actualizar', + onPressed: () => ref.read(etaProvider.notifier).refresh(), ), ], ), - 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); - }, - ), + body: etaAsync.when( + loading: () => const _EtaLoading(), + error: (e, _) => _EtaError( + error: e.toString(), + onRetry: () => ref.read(etaProvider.notifier).refresh(), + ), + data: (result) => result.hasAddress + ? _EtaContent(result: result) + : _NoAddressState(onAdd: () => context.go('/addresses/new')), + ), ); } } -// ── Widget para la Tarjeta de Mapa y ETA ───────────────────────────────────── -class _HouseEtaCard extends StatelessWidget { - final UIHouseModel casa; - final String etaMsg; - final String horario; - - const _HouseEtaCard({ - required this.casa, - required this.etaMsg, - required this.horario, - }); +// ───────────────────────────────────────────────────────────────────────────── +// Contenido principal +// ───────────────────────────────────────────────────────────────────────────── +class _EtaContent extends StatelessWidget { + final _EtaResult result; + const _EtaContent({required this.result}); @override Widget build(BuildContext context) { - // 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 RefreshIndicator( + onRefresh: () => ProviderScope.containerOf( + context, + ).read(etaProvider.notifier).refresh(), + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + children: [ + // ── 1. Hero card ──────────────────────────────────────────────── + _EtaHeroCard(result: result), + const SizedBox(height: 16), + + // ── 2. Domicilio registrado ───────────────────────────────────── + AppInfoRow( + icon: Icons.home_outlined, + label: 'Col. ${result.colonia}', + value: result.direccion.isEmpty ? 'Mi domicilio' : result.direccion, + trailing: AppStatusBadge.green('Activo'), + ), + const SizedBox(height: 12), + // ── 2.5. Mapa de ubicación ───────────────────────────────── + _MapaUbicacion( + colonia: result.colonia, + lat: result.lat, + lng: result.lng, + ), + const SizedBox(height: 12), + // ── 3. Pasos de progreso (justo debajo del domicilio) ─────────── + ProgressSteps(stepIndex: result.stepIndex), + const SizedBox(height: 12), + + // ── 4. Banner de prevención ───────────────────────────────────── + const PreventionBanner(), + const SizedBox(height: 12), + + // ── 5. Badge de suscripción FCM ───────────────────────────────── + const _FcmStatusBadge(), + const SizedBox(height: 16), + + // ── 6. Horario semanal ────────────────────────────────────────── + AppSectionTitle(title: 'Horario del camión'), + _HorarioCard(), + const SizedBox(height: 24), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Mapa de ubicación del domicilio (no interactivo) +// ───────────────────────────────────────────────────────────────────────────── +class _MapaUbicacion extends StatelessWidget { + final String colonia; + final double? lat; + final double? lng; + const _MapaUbicacion({required this.colonia, this.lat, this.lng}); + + @override + Widget build(BuildContext context) { + // Usar coordenadas del usuario si están disponibles, sino usar centro de colonia + final center = kColoniaCenter(colonia); + final pin = (lat != null && lng != null) ? LatLng(lat!, lng!) : center; return Container( + height: 200, decoration: BoxDecoration( - color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.radiusLg), - border: Border.all(color: AppTheme.border), - boxShadow: AppTheme.cardShadow, + border: Border.all(color: AppTheme.border, width: 1), ), clipBehavior: Clip.hardEdge, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + child: FlutterMap( + options: MapOptions( + initialCenter: pin, + initialZoom: 16.0, + interactionOptions: const InteractionOptions( + flags: InteractiveFlag.none, + ), + ), children: [ - // ── Mapa Restringido a la colonia ── - SizedBox( - height: 180, - child: FlutterMap( - options: MapOptions( - initialCenter: pin, - initialZoom: 16.0, - 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: pin, - width: 36, - height: 36, - child: const Icon( - Icons.home_rounded, - color: AppTheme.primary, - size: 36, - ), - ), - ], - ), - ], - ), + TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.onlineshack.recolecta', ), - - // ── Recuadro de Información ── - Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Icons.home, color: AppTheme.primary, size: 20), - const SizedBox(width: 8), - Text( - casa.alias.isNotEmpty ? casa.alias : 'Mi Domicilio', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: AppTheme.textPrimary, - ), - ), - ], - ), - const SizedBox(height: 14), - _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, - ), - const SizedBox(height: 18), - - // ── Alerta de ETA en Tiempo Real ── - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppTheme.primaryLight, - borderRadius: BorderRadius.circular(AppTheme.radiusSm), - border: Border.all(color: AppTheme.primaryMid), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon( - Icons.local_shipping_outlined, - color: AppTheme.primaryDark, - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Estado del Camión', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: AppTheme.primaryDark, - ), - ), - const SizedBox(height: 4), - Text( - etaMsg, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppTheme.primaryDark, - ), - ), - ], - ), - ), - ], - ), - ), - ], - ), - ), - ], - ), - ); - } -} - -// ── Fila auxiliar de info ──────────────────────────────────────────────────── -class _InfoRow extends StatelessWidget { - final IconData icon; - final String title; - final String value; - - const _InfoRow({ - required this.icon, - required this.title, - required this.value, - }); - - @override - Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(icon, size: 18, color: AppTheme.textSecondary), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - 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, + MarkerLayer( + markers: [ + Marker( + point: pin, + width: 36, + height: 36, + child: const Icon( + Icons.home_rounded, + color: AppTheme.primary, + size: 36, ), ), ], ), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Hero card: estado + ventana horaria + barra de progreso +// ───────────────────────────────────────────────────────────────────────────── +class _EtaHeroCard extends StatelessWidget { + final _EtaResult result; + const _EtaHeroCard({required this.result}); + + Color _bgColor(BuildContext context) { + final cs = Theme.of(context).colorScheme; + if (result.isCompleted) return cs.surfaceContainerHighest; + if (result.isNearby) return const Color(0xFFFFF8E1); // amber-50 + return const Color(0xFFE1F5EE); // teal-50 + } + + Color _accentColor(BuildContext context) { + if (result.isCompleted) return Theme.of(context).colorScheme.outline; + if (result.isNearby) return const Color(0xFFBA7517); // amber-400 + return const Color(0xFF1D9E75); // teal-400 + } + + @override + Widget build(BuildContext context) { + final accent = _accentColor(context); + final textTheme = Theme.of(context).textTheme; + + return AnimatedContainer( + duration: const Duration(milliseconds: 400), + curve: Curves.easeInOut, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: _bgColor(context), + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + border: Border.all(color: accent.withOpacity(0.3)), + boxShadow: AppTheme.softShadow, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Cabecera: icono + etiqueta + punto vivo + Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: accent, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.delete_outline_rounded, + color: Colors.white, + size: 24, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Camión recolector', + style: textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + color: accent, + ), + ), + const SizedBox(height: 2), + _StatusPill(result: result, accent: accent), + ], + ), + ), + _LiveDot(active: result.status == 'en_ruta'), + ], + ), + + const SizedBox(height: 16), + + // Ventana horaria o mensaje de estado + Text( + result.mensaje.isNotEmpty + ? result.mensaje + : _windowLabel(result.status), + style: textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w600, + color: accent, + height: 1.2, + ), + ), + + const SizedBox(height: 16), + + // Barra de progreso + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: result.progreso, + backgroundColor: accent.withOpacity(0.2), + valueColor: AlwaysStoppedAnimation(accent), + minHeight: 8, + ), + ), + const SizedBox(height: 6), + Row( + children: [ + Text( + 'Inicio de ruta', + style: TextStyle(fontSize: 10, color: accent.withOpacity(0.7)), + ), + const Spacer(), + Text( + 'Tu casa', + style: TextStyle(fontSize: 10, color: accent.withOpacity(0.7)), + ), + ], + ), + ], + ), + ); + } + + String _windowLabel(String s) { + switch (s) { + case 'completada': + return 'Servicio finalizado'; + case 'diferida': + return 'Servicio diferido'; + case 'reasignada': + return 'Ruta reasignada'; + default: + return 'En camino'; + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Pill de estado con punto pulsante +// ───────────────────────────────────────────────────────────────────────────── +class _StatusPill extends StatelessWidget { + final _EtaResult result; + final Color accent; + const _StatusPill({required this.result, required this.accent}); + + @override + Widget build(BuildContext context) { + final label = result.isNearby + ? 'Cerca de tu domicilio' + : result.isCompleted + ? 'Servicio completado' + : 'En camino a tu sector'; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!result.isCompleted) _PulsingDot(color: accent), + if (!result.isCompleted) const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: accent.withOpacity(0.15), + borderRadius: BorderRadius.circular(100), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: accent, + ), + ), ), ], ); } } + +// ───────────────────────────────────────────────────────────────────────────── +// Punto pulsante (animación de opacidad) +// ───────────────────────────────────────────────────────────────────────────── +class _PulsingDot extends StatefulWidget { + final Color color; + const _PulsingDot({required this.color}); + + @override + State<_PulsingDot> createState() => _PulsingDotState(); +} + +class _PulsingDotState extends State<_PulsingDot> + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + late final Animation _anim; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(reverse: true); + _anim = Tween(begin: 1.0, end: 0.3).animate(_ctrl); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _anim, + builder: (_, __) => Opacity( + opacity: _anim.value, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: widget.color, + shape: BoxShape.circle, + ), + ), + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Punto vivo "EN VIVO" (escala + opacidad) +// ───────────────────────────────────────────────────────────────────────────── +class _LiveDot extends StatefulWidget { + final bool active; + const _LiveDot({required this.active}); + + @override + State<_LiveDot> createState() => _LiveDotState(); +} + +class _LiveDotState extends State<_LiveDot> + with SingleTickerProviderStateMixin { + late final AnimationController _anim; + + @override + void initState() { + super.initState(); + _anim = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..repeat(reverse: true); + } + + @override + void dispose() { + _anim.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!widget.active) return const SizedBox.shrink(); + return AnimatedBuilder( + animation: _anim, + builder: (_, __) => Container( + width: 10, + height: 10, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppTheme.primary.withValues(alpha: 0.5 + _anim.value * 0.5), + ), + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Badge de suscripción FCM +// ───────────────────────────────────────────────────────────────────────────── +class _FcmStatusBadge extends ConsumerWidget { + const _FcmStatusBadge(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final routeId = ref.watch(activeRouteIdProvider); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), + ), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Color(0xFF1D9E75), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text.rich( + TextSpan( + children: [ + const TextSpan( + text: 'Notificaciones activas ', + style: TextStyle(fontWeight: FontWeight.w500), + ), + TextSpan( + text: routeId != null + ? 'para topic_$routeId' + : '— suscribiendo...', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + style: const TextStyle(fontSize: 12), + ), + ), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Horario semanal +// ───────────────────────────────────────────────────────────────────────────── +class _HorarioCard extends StatelessWidget { + static const _dias = [ + _HorarioDia(dia: 'Lunes', hora: '8:00 – 10:00 a.m.', activo: true), + _HorarioDia(dia: 'Martes', hora: '8:00 – 10:00 a.m.', activo: true), + _HorarioDia(dia: 'Miércoles', hora: 'Sin servicio', activo: false), + _HorarioDia(dia: 'Jueves', hora: '8:00 – 10:00 a.m.', activo: true), + _HorarioDia(dia: 'Viernes', hora: '8:00 – 10:00 a.m.', activo: true), + _HorarioDia(dia: 'Sábado', hora: '9:00 – 11:00 a.m.', activo: true), + _HorarioDia(dia: 'Domingo', hora: 'Sin servicio', activo: false), + ]; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + border: Border.all(color: AppTheme.border, width: 0.5), + boxShadow: AppTheme.softShadow, + ), + child: Column( + children: _dias.map((d) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 7), + child: Row( + children: [ + Text( + d.dia, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: d.activo + ? AppTheme.textPrimary + : AppTheme.textSecondary, + ), + ), + const Spacer(), + Text( + d.hora, + style: TextStyle( + fontSize: 13, + color: d.activo ? AppTheme.primary : AppTheme.textSecondary, + ), + ), + ], + ), + ); + }).toList(), + ), + ); + } +} + +class _HorarioDia { + final String dia; + final String hora; + final bool activo; + const _HorarioDia({ + required this.dia, + required this.hora, + required this.activo, + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Sin domicilio registrado +// ───────────────────────────────────────────────────────────────────────────── +class _NoAddressState extends StatelessWidget { + final VoidCallback onAdd; + const _NoAddressState({required this.onAdd}); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 80, + height: 80, + decoration: const BoxDecoration( + color: AppTheme.primaryLight, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.home_outlined, + color: AppTheme.primary, + size: 40, + ), + ), + const SizedBox(height: 20), + const Text( + 'Sin domicilio registrado', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 8), + const Text( + 'Registra tu domicilio para\nrecibir el ETA de tu ruta.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: AppTheme.textSecondary, + height: 1.5, + ), + ), + const SizedBox(height: 24), + SizedBox( + width: 200, + child: ElevatedButton( + onPressed: onAdd, + child: const Text('Agregar domicilio'), + ), + ), + ], + ), + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Cargando +// ───────────────────────────────────────────────────────────────────────────── +class _EtaLoading extends StatelessWidget { + const _EtaLoading(); + + @override + Widget build(BuildContext context) { + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(color: AppTheme.primary), + SizedBox(height: 16), + Text( + 'Consultando estado del servicio...', + style: TextStyle(color: AppTheme.textSecondary, fontSize: 14), + ), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Error +// ───────────────────────────────────────────────────────────────────────────── +class _EtaError extends StatelessWidget { + final String error; + final VoidCallback onRetry; + const _EtaError({required this.error, required this.onRetry}); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.wifi_off_rounded, + color: AppTheme.textSecondary, + size: 48, + ), + const SizedBox(height: 16), + const Text( + 'No se pudo obtener el estado', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 8), + Text( + error, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 20), + SizedBox( + width: 160, + child: FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh_rounded), + label: const Text('Reintentar'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/recolecta_app/lib/features/home/citizen_shell.dart b/recolecta_app/lib/features/home/citizen_shell.dart index 2025c5e..06aafc5 100644 --- a/recolecta_app/lib/features/home/citizen_shell.dart +++ b/recolecta_app/lib/features/home/citizen_shell.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../../core/widgets/app_widgets.dart'; +import '../../shared/widgets/eco_floating_button.dart'; class CitizenShell extends StatelessWidget { const CitizenShell({super.key, required this.child}); @@ -32,6 +33,7 @@ class CitizenShell extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( body: child, + floatingActionButton: const EcoFloatingButton(), bottomNavigationBar: AppBottomNav( currentIndex: _currentIndex(context), onTap: (i) => _onTap(context, i), diff --git a/recolecta_app/lib/features/home/main_shell.dart b/recolecta_app/lib/features/home/main_shell.dart index 0b6e3f3..e166d49 100644 --- a/recolecta_app/lib/features/home/main_shell.dart +++ b/recolecta_app/lib/features/home/main_shell.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import '../../core/widgets/app_widgets.dart'; -import '../eta/eta_screen.dart'; +import 'citizen_home_screen.dart'; import '../alerts/alerts_screen.dart'; import 'house_screen.dart'; import '../profile/profile_screen.dart'; @@ -16,7 +16,7 @@ class _MainShellState extends State { int _currentIndex = 0; static const List _screens = [ - EtaScreen(), + CitizenHomeScreen(), AlertsScreen(), MyHouseScreen(), ProfileScreen(), diff --git a/recolecta_app/lib/features/incidents/data/incident_service.dart b/recolecta_app/lib/features/incidents/data/incident_service.dart new file mode 100644 index 0000000..2d4f836 --- /dev/null +++ b/recolecta_app/lib/features/incidents/data/incident_service.dart @@ -0,0 +1,67 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http_parser/http_parser.dart'; + +import '../../../core/network/api_client.dart'; +import '../models/incident.dart'; + +final incidentServiceProvider = Provider((ref) { + return IncidentService(ref.read(apiClientProvider)); +}); + +class IncidentService { + IncidentService(this._dio); + final Dio _dio; + + Future> listUnits() async { + final res = await _dio.get>('/incidents/units'); + return (res.data ?? []) + .whereType() + .map((e) => UnitOption.fromJson(Map.from(e))) + .toList(); + } + + Future createIncident({ + required String category, + required String description, + int? unitId, + File? photo, + }) async { + final form = FormData.fromMap({ + 'category': category, + 'description': description, + if (unitId != null) 'unit_id': unitId, + if (photo != null) + 'photo': await MultipartFile.fromFile( + photo.path, + filename: photo.path.split(Platform.pathSeparator).last, + contentType: MediaType('image', _ext(photo.path)), + ), + }); + + final res = await _dio.post>( + '/incidents', + data: form, + options: Options(contentType: 'multipart/form-data'), + ); + return IncidentReport.fromJson(res.data!); + } + + Future> myIncidents() async { + final res = await _dio.get>('/incidents/me'); + return (res.data ?? []) + .whereType() + .map((e) => IncidentReport.fromJson(Map.from(e))) + .toList(); + } + + String _ext(String path) { + final dot = path.lastIndexOf('.'); + if (dot == -1) return 'jpeg'; + final ext = path.substring(dot + 1).toLowerCase(); + if (ext == 'jpg') return 'jpeg'; + return ext; + } +} diff --git a/recolecta_app/lib/features/incidents/models/incident.dart b/recolecta_app/lib/features/incidents/models/incident.dart new file mode 100644 index 0000000..8208e1c --- /dev/null +++ b/recolecta_app/lib/features/incidents/models/incident.dart @@ -0,0 +1,56 @@ +class UnitOption { + final int id; + final String? plate; + final String? status; + const UnitOption({required this.id, this.plate, this.status}); + + factory UnitOption.fromJson(Map json) => UnitOption( + id: json['id'] as int, + plate: json['plate'] as String?, + status: json['status'] as String?, + ); + + String get label => + plate != null && plate!.isNotEmpty ? 'Unidad $id · $plate' : 'Unidad $id'; +} + +class IncidentReport { + final int id; + final String userId; + final int? unitId; + final String category; + final String description; + final String? photoUrl; + final String status; + final String? createdAt; + + const IncidentReport({ + required this.id, + required this.userId, + this.unitId, + required this.category, + required this.description, + this.photoUrl, + required this.status, + this.createdAt, + }); + + factory IncidentReport.fromJson(Map json) => IncidentReport( + id: json['id'] as int, + userId: json['user_id'] as String, + unitId: json['unit_id'] as int?, + category: json['category'] as String, + description: json['description'] as String, + photoUrl: json['photo_url'] as String?, + status: (json['status'] as String?) ?? 'open', + createdAt: json['created_at'] as String?, + ); +} + +const incidentCategories = { + 'derrame': 'Derrame de residuos', + 'dano_propiedad': 'Daño a propiedad', + 'conducta': 'Conducta inadecuada', + 'no_recoleccion': 'No pasó el camión', + 'otro': 'Otro', +}; diff --git a/recolecta_app/lib/features/incidents/providers/incident_providers.dart b/recolecta_app/lib/features/incidents/providers/incident_providers.dart new file mode 100644 index 0000000..ac3126b --- /dev/null +++ b/recolecta_app/lib/features/incidents/providers/incident_providers.dart @@ -0,0 +1,12 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/incident_service.dart'; +import '../models/incident.dart'; + +final unitsProvider = FutureProvider>((ref) async { + return ref.read(incidentServiceProvider).listUnits(); +}); + +final myIncidentsProvider = FutureProvider>((ref) async { + return ref.read(incidentServiceProvider).myIncidents(); +}); diff --git a/recolecta_app/lib/features/incidents/screens/report_issue_screen.dart b/recolecta_app/lib/features/incidents/screens/report_issue_screen.dart new file mode 100644 index 0000000..6c2b38a --- /dev/null +++ b/recolecta_app/lib/features/incidents/screens/report_issue_screen.dart @@ -0,0 +1,244 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:dio/dio.dart'; + +import '../../../core/theme/app_theme.dart'; +import '../../../core/widgets/app_widgets.dart'; +import '../data/incident_service.dart'; +import '../models/incident.dart'; +import '../providers/incident_providers.dart'; + +class ReportIssueScreen extends ConsumerStatefulWidget { + const ReportIssueScreen({super.key}); + + @override + ConsumerState createState() => _ReportIssueScreenState(); +} + +class _ReportIssueScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _descCtrl = TextEditingController(); + + int? _unitId; + String _category = 'no_recoleccion'; + File? _photo; + bool _submitting = false; + + @override + void dispose() { + _descCtrl.dispose(); + super.dispose(); + } + + Future _pickPhoto() async { + final picker = ImagePicker(); + final picked = await picker.pickImage( + source: ImageSource.gallery, + maxWidth: 1600, + imageQuality: 80, + ); + if (picked != null) { + setState(() => _photo = File(picked.path)); + } + } + + Future _submit() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + setState(() => _submitting = true); + try { + await ref + .read(incidentServiceProvider) + .createIncident( + category: _category, + description: _descCtrl.text.trim(), + unitId: _unitId, + photo: _photo, + ); + ref.invalidate(myIncidentsProvider); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Reporte enviado. ¡Gracias!')), + ); + Navigator.of(context).pop(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(_friendly(e)))); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + String _friendly(Object e) { + if (e is DioException) { + final data = e.response?.data; + if (data is Map && data['detail'] != null) + return data['detail'].toString(); + } + return 'No se pudo enviar el reporte'; + } + + @override + Widget build(BuildContext context) { + final unitsAsync = ref.watch(unitsProvider); + + return Scaffold( + backgroundColor: AppTheme.background, + appBar: AppBar(title: const Text('Reportar un problema')), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const AppSectionTitle(title: 'Detalles del reporte'), + + // Unidad + Text( + 'Unidad relacionada (opcional)', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 6), + unitsAsync.when( + loading: () => const LinearProgressIndicator(), + error: (e, _) => Text( + 'No se pudieron cargar las unidades', + style: const TextStyle( + color: AppTheme.danger, + fontSize: 12, + ), + ), + data: (units) => DropdownButtonFormField( + initialValue: _unitId, + isExpanded: true, + items: [ + const DropdownMenuItem( + value: null, + child: Text('Sin unidad'), + ), + for (final u in units) + DropdownMenuItem( + value: u.id, + child: Text(u.label), + ), + ], + onChanged: (v) => setState(() => _unitId = v), + ), + ), + + const SizedBox(height: 16), + + // Categoría + Text( + 'Categoría', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 6), + DropdownButtonFormField( + initialValue: _category, + isExpanded: true, + items: [ + for (final entry in incidentCategories.entries) + DropdownMenuItem( + value: entry.key, + child: Text(entry.value), + ), + ], + onChanged: (v) { + if (v != null) setState(() => _category = v); + }, + validator: (v) => (v == null || v.isEmpty) + ? 'Selecciona una categoría' + : null, + ), + + const SizedBox(height: 16), + + AppFormField( + label: 'Descripción', + controller: _descCtrl, + hint: 'Cuéntanos qué pasó…', + maxLines: 5, + keyboardType: TextInputType.multiline, + validator: (v) { + final t = (v ?? '').trim(); + if (t.length < 3) + return 'Describe el problema (mínimo 3 caracteres)'; + return null; + }, + ), + + const SizedBox(height: 16), + + // Foto + Row( + children: [ + OutlinedButton.icon( + onPressed: _submitting ? null : _pickPhoto, + icon: const Icon(Icons.photo_camera_outlined), + label: Text( + _photo == null ? 'Adjuntar foto' : 'Cambiar foto', + ), + ), + if (_photo != null) ...[ + const SizedBox(width: 8), + IconButton( + tooltip: 'Quitar foto', + icon: const Icon(Icons.close, color: AppTheme.danger), + onPressed: _submitting + ? null + : () => setState(() => _photo = null), + ), + ], + ], + ), + if (_photo != null) ...[ + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + child: Image.file( + _photo!, + height: 180, + width: double.infinity, + fit: BoxFit.cover, + ), + ), + ], + + const SizedBox(height: 20), + + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _submitting ? null : _submit, + child: _submitting + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Enviar reporte'), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/recolecta_app/lib/features/profile/data/profile_service.dart b/recolecta_app/lib/features/profile/data/profile_service.dart new file mode 100644 index 0000000..804babc --- /dev/null +++ b/recolecta_app/lib/features/profile/data/profile_service.dart @@ -0,0 +1,38 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/network/api_client.dart'; +import '../models/profile_user.dart'; + +final profileServiceProvider = Provider((ref) { + return ProfileService(ref.read(apiClientProvider)); +}); + +class ProfileService { + ProfileService(this._dio); + final Dio _dio; + + Future getMe() async { + final res = await _dio.get>('/users/me'); + return ProfileUser.fromJson(res.data!); + } + + Future updateMe({String? name, String? email, String? phone}) async { + final payload = {}; + if (name != null) payload['name'] = name; + if (email != null) payload['email'] = email; + if (phone != null) payload['phone'] = phone; + if (payload.isEmpty) return; + await _dio.patch('/users/me', data: payload); + } + + Future changePassword({ + required String currentPassword, + required String newPassword, + }) async { + await _dio.post( + '/users/me/change-password', + data: {'current_password': currentPassword, 'new_password': newPassword}, + ); + } +} diff --git a/recolecta_app/lib/features/profile/edit_profile_screen.dart b/recolecta_app/lib/features/profile/edit_profile_screen.dart index c82ec6f..e505c14 100644 --- a/recolecta_app/lib/features/profile/edit_profile_screen.dart +++ b/recolecta_app/lib/features/profile/edit_profile_screen.dart @@ -1,125 +1,409 @@ +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.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'; + +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/app_widgets.dart'; +import 'data/profile_service.dart'; +import 'providers/profile_providers.dart'; class EditProfileScreen extends ConsumerStatefulWidget { const EditProfileScreen({super.key}); @override - ConsumerState createState() => - _EditProfileScreenState(); + 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) - } + final _nameCtrl = TextEditingController(); + final _emailCtrl = TextEditingController(); + final _phoneCtrl = TextEditingController(); + + final _currentPasswordCtrl = TextEditingController(); + final _newPasswordCtrl = TextEditingController(); + final _confirmPasswordCtrl = TextEditingController(); + + bool _saving = false; + bool _prefilled = false; @override void dispose() { - _nameController.dispose(); - _emailController.dispose(); + _nameCtrl.dispose(); + _emailCtrl.dispose(); + _phoneCtrl.dispose(); + _currentPasswordCtrl.dispose(); + _newPasswordCtrl.dispose(); + _confirmPasswordCtrl.dispose(); super.dispose(); } - Future _saveProfile() async { - if (!_formKey.currentState!.validate()) { - return; + void _prefill(Map data) { + if (_prefilled) return; + _nameCtrl.text = data['name'] ?? ''; + _emailCtrl.text = data['email'] ?? ''; + _phoneCtrl.text = _formatPhoneInitial(data['phone']); + _prefilled = true; + } + + // Normaliza un teléfono almacenado (con o sin lada/guiones) al formato 000-000-0000 + String _formatPhoneInitial(String? raw) { + if (raw == null || raw.isEmpty) return ''; + final digits = raw.replaceAll(RegExp(r'\D'), ''); + final last10 = digits.length > 10 + ? digits.substring(digits.length - 10) + : digits; + if (last10.length <= 3) return last10; + if (last10.length <= 6) { + return '${last10.substring(0, 3)}-${last10.substring(3)}'; } - setState(() { - _isLoading = true; - }); + return '${last10.substring(0, 3)}-${last10.substring(3, 6)}-${last10.substring(6)}'; + } + String _friendly(Object e) { + if (e is DioException) { + final data = e.response?.data; + if (data is Map && data['detail'] != null) { + return data['detail'].toString(); + } + return e.message ?? 'Error de red'; + } + return e.toString(); + } + + bool get _wantsPasswordChange => + _currentPasswordCtrl.text.isNotEmpty || + _newPasswordCtrl.text.isNotEmpty || + _confirmPasswordCtrl.text.isNotEmpty; + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _saving = true); + final messenger = ScaffoldMessenger.of(context); try { - final apiService = ref.read(apiServiceProvider); - await apiService.updateUser({ - 'name': _nameController.text, - 'email': _emailController.text, - }); + await ref + .read(profileServiceProvider) + .updateMe( + name: _nameCtrl.text.trim(), + email: _emailCtrl.text.trim().isEmpty + ? null + : _emailCtrl.text.trim(), + phone: _phoneCtrl.text.trim().isEmpty + ? null + : _phoneCtrl.text.trim(), + ); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Perfil actualizado con éxito')), - ); - Navigator.of(context).pop(); + if (_wantsPasswordChange) { + await ref + .read(profileServiceProvider) + .changePassword( + currentPassword: _currentPasswordCtrl.text, + newPassword: _newPasswordCtrl.text, + ); + _currentPasswordCtrl.clear(); + _newPasswordCtrl.clear(); + _confirmPasswordCtrl.clear(); } + + ref.invalidate(currentUserProvider); + if (!mounted) return; + messenger.showSnackBar( + SnackBar( + content: Text( + _wantsPasswordChange + ? 'Perfil y contraseña actualizados' + : 'Perfil actualizado', + ), + ), + ); } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error al actualizar el perfil: $e')), - ); - } + if (!mounted) return; + messenger.showSnackBar(SnackBar(content: Text('Error: ${_friendly(e)}'))); } finally { - if (mounted) { - setState(() { - _isLoading = false; - }); - } + if (mounted) setState(() => _saving = false); } } @override Widget build(BuildContext context) { + final userAsync = ref.watch(currentUserProvider); + 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; - }, + backgroundColor: AppTheme.background, + appBar: AppBar(title: const Text('Editar perfil')), + body: userAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + 'No se pudo cargar el perfil:\n${_friendly(e)}', + textAlign: TextAlign.center, + style: const TextStyle(color: AppTheme.danger), ), - 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; - }, - ), - ], + ), ), + data: (user) { + _prefill({ + 'name': user.name, + 'email': user.email, + 'phone': user.phone, + }); + + return Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + const AppSectionTitle(title: 'Datos personales'), + AppCard( + child: Column( + children: [ + AppFormField( + label: 'Nombre', + controller: _nameCtrl, + validator: (v) => (v == null || v.trim().isEmpty) + ? 'Ingresa tu nombre' + : null, + ), + const SizedBox(height: 12), + AppFormField( + label: 'Correo electrónico', + controller: _emailCtrl, + keyboardType: TextInputType.emailAddress, + validator: (v) { + if (v == null || v.trim().isEmpty) return null; + if (!v.contains('@')) return 'Correo inválido'; + return null; + }, + ), + const SizedBox(height: 12), + _PhoneField(controller: _phoneCtrl), + ], + ), + ), + const SizedBox(height: 20), + const AppSectionTitle(title: 'Cambiar contraseña'), + AppCard( + child: Column( + children: [ + AppFormField( + label: 'Contraseña actual', + controller: _currentPasswordCtrl, + obscureText: true, + validator: (v) { + if (!_wantsPasswordChange) return null; + if (v == null || v.length < 6) { + return 'Mínimo 6 caracteres'; + } + return null; + }, + ), + const SizedBox(height: 12), + AppFormField( + label: 'Nueva contraseña', + controller: _newPasswordCtrl, + obscureText: true, + validator: (v) { + if (!_wantsPasswordChange) return null; + if (v == null || v.length < 6) { + return 'Mínimo 6 caracteres'; + } + return null; + }, + ), + const SizedBox(height: 12), + AppFormField( + label: 'Confirmar nueva contraseña', + controller: _confirmPasswordCtrl, + obscureText: true, + validator: (v) { + if (!_wantsPasswordChange) return null; + if (v == null || v.isEmpty) { + return 'Confirma la contraseña'; + } + if (v != _newPasswordCtrl.text) return 'No coincide'; + return null; + }, + ), + const SizedBox(height: 8), + const Align( + alignment: Alignment.centerLeft, + child: Text( + 'Déjalo en blanco si no deseas cambiarla.', + style: TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Guardar cambios'), + ), + ), + const SizedBox(height: 24), + ], + ), + ); + }, ), ); } } + +// ── Campo de teléfono con lada +52 y formato 000-000-0000 ───────────────────── +class _PhoneField extends StatelessWidget { + final TextEditingController controller; + const _PhoneField({required this.controller}); + + 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: [ + 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), + 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; + }, + ), + ), + ], + ), + ], + ); + } +} + +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), + ); + } +} diff --git a/recolecta_app/lib/features/profile/models/profile_user.dart b/recolecta_app/lib/features/profile/models/profile_user.dart new file mode 100644 index 0000000..2d31c88 --- /dev/null +++ b/recolecta_app/lib/features/profile/models/profile_user.dart @@ -0,0 +1,43 @@ +class ProfileUser { + final String id; + final String? email; + final String? phone; + final String? name; + final String role; + final String? createdAt; + + const ProfileUser({ + required this.id, + this.email, + this.phone, + this.name, + required this.role, + this.createdAt, + }); + + factory ProfileUser.fromJson(Map json) => ProfileUser( + id: json['id'] as String, + email: json['email'] as String?, + phone: json['phone'] as String?, + name: json['name'] as String?, + role: (json['role'] as String?) ?? 'citizen', + createdAt: json['created_at'] as String?, + ); + + bool get isAdmin => role == 'admin'; + bool get isDriver => role == 'driver'; + + String get displayName { + if (name != null && name!.trim().isNotEmpty) return name!.trim(); + if (email != null && email!.isNotEmpty) return email!; + return 'Usuario'; + } + + String get initials { + final source = (name != null && name!.trim().isNotEmpty) + ? name!.trim() + : (email ?? ''); + if (source.isEmpty) return 'U'; + return source[0].toUpperCase(); + } +} diff --git a/recolecta_app/lib/features/profile/profile_screen.dart b/recolecta_app/lib/features/profile/profile_screen.dart index 9fcc75b..d29e00e 100644 --- a/recolecta_app/lib/features/profile/profile_screen.dart +++ b/recolecta_app/lib/features/profile/profile_screen.dart @@ -5,144 +5,112 @@ import 'package:go_router/go_router.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_widgets.dart'; import '../../core/services/auth_controller.dart'; -import '../../core/storage/secure_storage.dart'; -import '../../core/constants/auth_constants.dart'; import '../separation_guide/ai_pet_chat_screen.dart'; +import 'models/profile_user.dart'; +import 'providers/profile_providers.dart'; class ProfileScreen extends ConsumerWidget { const ProfileScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final authState = ref.watch(authControllerProvider).asData?.value; - final storage = ref.read(secureStorageProvider); + final authRole = + ref.watch(authControllerProvider).asData?.value.userRole ?? 'citizen'; + final userAsync = ref.watch(currentUserProvider); return Scaffold( backgroundColor: AppTheme.background, appBar: AppBar(title: const Text('Mi perfil')), - body: FutureBuilder<_ProfileData>( - future: _loadProfile(storage), - builder: (context, snapshot) { - final profile = - snapshot.data ?? - _ProfileData( - email: authState?.token != null ? '…' : '', - role: authState?.userRole ?? 'citizen', - ); - - return ListView( - padding: const EdgeInsets.all(16), - children: [ - _ProfileHeader(profile: profile), - const SizedBox(height: 20), - - const AppSectionTitle(title: 'Mi cuenta'), - AppMenuTile( - icon: Icons.person_outline, - title: 'Editar perfil', - subtitle: profile.email, - onTap: () => context.go('/edit-profile'), - ), - AppMenuTile( - icon: Icons.lock_outline, - title: 'Cambiar contraseña', - onTap: () {}, - ), - AppMenuTile( - icon: Icons.email_outlined, - title: 'Correo', - subtitle: profile.email, - onTap: () {}, - ), - - const SizedBox(height: 16), - const AppSectionTitle(title: 'Configuración'), - AppMenuTile( - icon: Icons.calendar_month_outlined, - title: 'Horario del camión', - subtitle: 'Mi ruta asignada', - onTap: () {}, - ), - AppMenuTile( - icon: Icons.notifications_outlined, - title: 'Notificaciones', - subtitle: 'Gestiona tus alertas', - onTap: () {}, - ), - if (profile.role == 'admin') - AppMenuTile( - icon: Icons.admin_panel_settings_outlined, - title: 'Panel de administración', - subtitle: 'Gestiona usuarios, rutas y camiones', - onTap: () => context.go('/admin'), - ), - - const SizedBox(height: 16), - const AppSectionTitle(title: 'Soporte'), - AppMenuTile( - icon: Icons.pets, - title: 'Hablar con Eco (Asistente IA)', - subtitle: 'Guía de separación de residuos', - onTap: () { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => const AiPetChatScreen()), - ); - }, - ), - AppMenuTile( - icon: Icons.help_outline, - title: 'Ayuda y preguntas frecuentes', - onTap: () {}, - ), - AppMenuTile( - icon: Icons.bug_report_outlined, - title: 'Reportar un problema', - onTap: () {}, - ), - AppMenuTile( - icon: Icons.info_outline, - title: 'Acerca de la app', - subtitle: 'Versión 1.0.0', - onTap: () {}, - ), - - const SizedBox(height: 16), - AppMenuTile( - icon: Icons.logout_rounded, - title: 'Cerrar sesión', - iconColor: AppTheme.danger, - titleColor: AppTheme.danger, - trailing: const SizedBox.shrink(), - onTap: () => _confirmarCerrarSesion(context, ref), - ), - - const SizedBox(height: 32), - const Center( - child: Text( - 'Recolecta v1.0.0\nServicio de Limpia · Celaya, Gto.', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 12, - color: AppTheme.textHint, - height: 1.6, - ), - ), - ), - const SizedBox(height: 24), - ], - ); + body: RefreshIndicator( + onRefresh: () async { + ref.invalidate(currentUserProvider); + await ref.read(currentUserProvider.future); }, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _ProfileHeader( + user: userAsync.asData?.value, + fallbackRole: authRole, + ), + const SizedBox(height: 20), + + const AppSectionTitle(title: 'Mi cuenta'), + AppMenuTile( + icon: Icons.person_outline, + title: 'Editar perfil', + subtitle: 'Nombre, correo, teléfono y contraseña', + onTap: () => context.push('/edit-profile'), + ), + if ((userAsync.asData?.value.isAdmin ?? false) || + authRole == 'admin') + AppMenuTile( + icon: Icons.admin_panel_settings_outlined, + title: 'Panel de administración', + subtitle: 'Gestiona usuarios, rutas y camiones', + onTap: () => context.go('/admin'), + ), + + const SizedBox(height: 16), + const AppSectionTitle(title: 'Soporte'), + AppMenuTile( + icon: Icons.pets, + title: 'Hablar con Eco (Asistente IA)', + subtitle: 'Guía de separación de residuos', + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const AiPetChatScreen()), + ); + }, + ), + AppMenuTile( + icon: Icons.help_outline, + title: 'Ayuda y preguntas frecuentes', + subtitle: 'Chatea con nuestro asistente', + onTap: () => context.push('/help'), + ), + AppMenuTile( + icon: Icons.bug_report_outlined, + title: 'Reportar un problema', + subtitle: 'Reporta una unidad o incidente', + onTap: () => context.push('/report-issue'), + ), + AppMenuTile( + icon: Icons.info_outline, + title: 'Acerca de la app', + onTap: () => context.push('/about'), + ), + + const SizedBox(height: 16), + AppMenuTile( + icon: Icons.logout_rounded, + title: 'Cerrar sesión', + iconColor: AppTheme.danger, + titleColor: AppTheme.danger, + trailing: const SizedBox.shrink(), + onTap: () => _confirmarCerrarSesion(context, ref), + ), + + const SizedBox(height: 32), + const Center( + child: Text( + 'Recolecta v1.0.0\nServicio de Limpia · Celaya, Gto.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + color: AppTheme.textHint, + height: 1.6, + ), + ), + ), + const SizedBox(height: 24), + ], + ), ), ); } - Future<_ProfileData> _loadProfile(dynamic storage) async { - final role = - await storage.read(key: authUserRoleStorageKey) as String? ?? 'citizen'; - return _ProfileData(role: role); - } - void _confirmarCerrarSesion(BuildContext context, WidgetRef ref) { showDialog( context: context, @@ -189,26 +157,21 @@ class ProfileScreen extends ConsumerWidget { } } -// ── Datos de perfil ─────────────────────────────────────────────────────────── -class _ProfileData { - final String email; - final String role; - - const _ProfileData({this.email = '', this.role = 'citizen'}); - - String get iniciales => email.isNotEmpty ? email[0].toUpperCase() : 'U'; - - String get displayName => email; - bool get isAdmin => role == 'admin'; -} - // ── Encabezado ──────────────────────────────────────────────────────────────── class _ProfileHeader extends StatelessWidget { - final _ProfileData profile; - const _ProfileHeader({required this.profile}); + final ProfileUser? user; + final String fallbackRole; + const _ProfileHeader({required this.user, required this.fallbackRole}); @override Widget build(BuildContext context) { + final role = user?.role ?? fallbackRole; + final isAdmin = role == 'admin'; + final isDriver = role == 'driver'; + final initials = user?.initials ?? 'U'; + final displayName = user?.displayName ?? 'Usuario'; + final email = user?.email ?? '…'; + return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -229,7 +192,7 @@ class _ProfileHeader extends StatelessWidget { ), child: Center( child: Text( - profile.iniciales, + initials, style: const TextStyle( fontSize: 20, fontWeight: FontWeight.w700, @@ -244,7 +207,7 @@ class _ProfileHeader extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - profile.displayName, + displayName, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w700, @@ -253,7 +216,7 @@ class _ProfileHeader extends StatelessWidget { ), const SizedBox(height: 2), Text( - profile.email, + email, style: const TextStyle( fontSize: 13, color: AppTheme.textSecondary, @@ -261,7 +224,11 @@ class _ProfileHeader extends StatelessWidget { ), const SizedBox(height: 6), AppStatusBadge.green( - profile.isAdmin ? 'Administrador' : 'Ciudadano', + isAdmin + ? 'Administrador' + : isDriver + ? 'Chofer' + : 'Ciudadano', ), ], ), @@ -271,3 +238,4 @@ class _ProfileHeader extends StatelessWidget { ); } } + diff --git a/recolecta_app/lib/features/profile/providers/profile_providers.dart b/recolecta_app/lib/features/profile/providers/profile_providers.dart new file mode 100644 index 0000000..1e2691e --- /dev/null +++ b/recolecta_app/lib/features/profile/providers/profile_providers.dart @@ -0,0 +1,8 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/profile_service.dart'; +import '../models/profile_user.dart'; + +final currentUserProvider = FutureProvider((ref) async { + return ref.read(profileServiceProvider).getMe(); +}); diff --git a/recolecta_app/lib/shared/widgets/eco_floating_button.dart b/recolecta_app/lib/shared/widgets/eco_floating_button.dart new file mode 100644 index 0000000..f7a8c94 --- /dev/null +++ b/recolecta_app/lib/shared/widgets/eco_floating_button.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import '../../core/theme/app_theme.dart'; +import '../../features/separation_guide/ai_pet_chat_screen.dart'; + +class EcoFloatingButton extends StatelessWidget { + const EcoFloatingButton({super.key}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const AiPetChatScreen()), + ); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // ── Globo de texto ── + Container( + margin: const EdgeInsets.only(bottom: 12, right: 8), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + bottomLeft: Radius.circular(16), + bottomRight: Radius.circular(4), + ), + boxShadow: AppTheme.softShadow, + border: Border.all(color: AppTheme.primaryMid, width: 1.5), + ), + child: const Text( + 'Soy Eco, tu asistente', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppTheme.primaryDark, + ), + ), + ), + + // ── Mascota Circular (GIF) ── + Container( + width: 65, + height: 65, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppTheme.primaryLight, + boxShadow: AppTheme.cardShadow, + border: Border.all(color: AppTheme.primary, width: 2.5), + ), + clipBehavior: Clip.hardEdge, + child: Transform.scale( + scale: 1.5, // Ajusta este número si quieres el GIF más grande o pequeño + child: Image.asset( + 'assets/animations/info.gif', + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return const Icon( + Icons.pets, + color: AppTheme.primary, + size: 32, + ); + }, + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/recolecta_app/linux/flutter/generated_plugin_registrant.cc b/recolecta_app/linux/flutter/generated_plugin_registrant.cc index d0e7f79..85a2413 100644 --- a/recolecta_app/linux/flutter/generated_plugin_registrant.cc +++ b/recolecta_app/linux/flutter/generated_plugin_registrant.cc @@ -6,9 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); diff --git a/recolecta_app/linux/flutter/generated_plugins.cmake b/recolecta_app/linux/flutter/generated_plugins.cmake index ce58916..7aea3ec 100644 --- a/recolecta_app/linux/flutter/generated_plugins.cmake +++ b/recolecta_app/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux flutter_secure_storage_linux ) diff --git a/recolecta_app/macos/Flutter/GeneratedPluginRegistrant.swift b/recolecta_app/macos/Flutter/GeneratedPluginRegistrant.swift index 677cdb0..a8d5d9d 100644 --- a/recolecta_app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/recolecta_app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,18 +5,22 @@ import FlutterMacOS import Foundation +import file_selector_macos import firebase_core import firebase_messaging import flutter_local_notifications import flutter_secure_storage_macos +import package_info_plus import sqflite_darwin import video_player_avfoundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) VideoPlayerPlugin.register(with: registry.registrar(forPlugin: "VideoPlayerPlugin")) } diff --git a/recolecta_app/pubspec.lock b/recolecta_app/pubspec.lock index f3aa26f..361a98b 100644 --- a/recolecta_app/pubspec.lock +++ b/recolecta_app/pubspec.lock @@ -129,6 +129,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.15.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: transitive description: @@ -201,6 +209,38 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" firebase_core: dependency: "direct main" description: @@ -318,6 +358,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.2.1" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" flutter_riverpod: dependency: "direct main" description: @@ -441,13 +489,77 @@ packages: source: hosted version: "3.2.2" http_parser: - dependency: transitive + dependency: "direct main" description: name: http_parser sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" url: "https://pub.dev" source: hosted version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "91c025426c2881c551100bce834e201c835a170151545f58d17da5180ca7d9ac" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: d5b3e1774af29c9ab00103afb0d4614070f924d2e0057ac867ec98800114793f + url: "https://pub.dev" + source: hosted + version: "0.8.13+17" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" intl: dependency: transitive description: @@ -624,6 +736,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + url: "https://pub.dev" + source: hosted + version: "8.3.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: transitive description: diff --git a/recolecta_app/pubspec.yaml b/recolecta_app/pubspec.yaml index d6197e9..a36719a 100644 --- a/recolecta_app/pubspec.yaml +++ b/recolecta_app/pubspec.yaml @@ -46,6 +46,9 @@ dependencies: flutter_map: ^6.1.0 latlong2: ^0.9.0 video_player: ^2.9.2 + package_info_plus: ^8.0.0 + image_picker: ^1.1.2 + http_parser: ^4.0.2 dev_dependencies: flutter_test: @@ -68,6 +71,7 @@ flutter: - assets/.env - assets/data/separation_guide.json - assets/animations/blink_saludo.gif + - assets/animations/info.gif - assets/animations/ # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in diff --git a/recolecta_app/windows/flutter/generated_plugin_registrant.cc b/recolecta_app/windows/flutter/generated_plugin_registrant.cc index 39cedd3..8e326bc 100644 --- a/recolecta_app/windows/flutter/generated_plugin_registrant.cc +++ b/recolecta_app/windows/flutter/generated_plugin_registrant.cc @@ -6,10 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); FirebaseCorePluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( diff --git a/recolecta_app/windows/flutter/generated_plugins.cmake b/recolecta_app/windows/flutter/generated_plugins.cmake index b1ad9e1..4b110c9 100644 --- a/recolecta_app/windows/flutter/generated_plugins.cmake +++ b/recolecta_app/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows firebase_core flutter_secure_storage_windows )