vistas de ciudadano, escalar animaciones de mascota, implementacion de chatbot para concientizacion, modificacion de datos de ciudadano, modificacion de vista principal
This commit is contained in:
73
backend/app/api/chat.py
Normal file
73
backend/app/api/chat.py
Normal file
@@ -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())
|
||||||
132
backend/app/api/incidents.py
Normal file
132
backend/app/api/incidents.py
Normal file
@@ -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
|
||||||
@@ -1,43 +1,126 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel, EmailStr
|
|
||||||
from app.core.deps import get_current_user
|
from app.core.deps import get_current_user
|
||||||
from app.core.supabase_client import supabase_admin
|
from app.core.supabase_client import supabase, supabase_admin
|
||||||
from gotrue.types import User
|
from app.schemas.users import UserMe, UserUpdateMe, ChangePasswordIn
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["users"])
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
|
||||||
name: str | None = None
|
@router.get("/me", response_model=UserMe)
|
||||||
email: EmailStr | None = None
|
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)
|
@router.patch("/me", status_code=204)
|
||||||
def update_user_profile(
|
def update_me(
|
||||||
update_data: UserUpdate,
|
body: UserUpdateMe,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""
|
"""Actualiza nombre / email / teléfono del usuario autenticado."""
|
||||||
Actualiza el perfil del usuario autenticado (nombre, email).
|
user_id = current_user["user_id"]
|
||||||
"""
|
|
||||||
user_id = current_user.id
|
|
||||||
update_payload = {}
|
|
||||||
|
|
||||||
if update_data.name:
|
if body.name is not None:
|
||||||
# El nombre no está en Supabase Auth, sino en nuestra tabla `users`
|
|
||||||
try:
|
try:
|
||||||
supabase_admin.table("users").update({"name": update_data.name}).eq(
|
supabase_admin.table("users").update({"name": body.name}).eq(
|
||||||
"id", user_id
|
"id", user_id
|
||||||
).execute()
|
).execute()
|
||||||
except Exception as e:
|
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:
|
auth_payload: dict = {}
|
||||||
# El email sí está en Supabase Auth
|
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:
|
try:
|
||||||
supabase_admin.auth.admin.update_user_by_id(
|
supabase_admin.auth.admin.update_user_by_id(user_id, auth_payload)
|
||||||
user_id, {"email": update_data.email}
|
|
||||||
)
|
|
||||||
except Exception as e:
|
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
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ class Settings(BaseSettings):
|
|||||||
FIREBASE_CREDENTIALS_PATH: str = "app/data/secrets/recoleccion-app-firebase-adminsdk-fbsvc-3da79d2a4c.json"
|
FIREBASE_CREDENTIALS_PATH: str = "app/data/secrets/recoleccion-app-firebase-adminsdk-fbsvc-3da79d2a4c.json"
|
||||||
SIMULATION_TICK_SECONDS: int = 10
|
SIMULATION_TICK_SECONDS: int = 10
|
||||||
|
|
||||||
|
OPENAI_API_KEY: str | None = None
|
||||||
|
OPENAI_MODEL: str = "gpt-4o-mini"
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
@@ -137,6 +137,29 @@ CREATE POLICY "feedback_insert" ON public.feedback
|
|||||||
FOR INSERT WITH CHECK (auth.uid() = user_id);
|
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
|
-- 6. Índices útiles para rendimiento
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
|
|||||||
@@ -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_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_drivers_user ON public.drivers(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_collection_route ON public.collection_events(route_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);
|
||||||
|
|||||||
32
backend/app/schemas/incidents.py
Normal file
32
backend/app/schemas/incidents.py
Normal file
@@ -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)
|
||||||
21
backend/app/schemas/users.py
Normal file
21
backend/app/schemas/users.py
Normal file
@@ -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)
|
||||||
@@ -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.users import router as users_router
|
||||||
from app.api.admin import router as admin_router
|
from app.api.admin import router as admin_router
|
||||||
from app.api.simulation import router as simulation_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
|
from app.services import simulation, notifications
|
||||||
|
|
||||||
scheduler = AsyncIOScheduler()
|
scheduler = AsyncIOScheduler()
|
||||||
@@ -63,3 +65,5 @@ app.include_router(colonias_router)
|
|||||||
app.include_router(users_router)
|
app.include_router(users_router)
|
||||||
app.include_router(admin_router)
|
app.include_router(admin_router)
|
||||||
app.include_router(simulation_router)
|
app.include_router(simulation_router)
|
||||||
|
app.include_router(chat_router)
|
||||||
|
app.include_router(incidents_router)
|
||||||
|
|||||||
@@ -9,3 +9,5 @@ sqlalchemy==2.0.30
|
|||||||
psycopg2-binary==2.9.9
|
psycopg2-binary==2.9.9
|
||||||
python-jose[cryptography]==3.3.0
|
python-jose[cryptography]==3.3.0
|
||||||
passlib[bcrypt]==1.7.4
|
passlib[bcrypt]==1.7.4
|
||||||
|
openai>=1.40.0
|
||||||
|
httpx>=0.27.0
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ import 'package:recolecta_app/core/services/auth_controller.dart';
|
|||||||
import '../../features/addresses/add_address_page.dart';
|
import '../../features/addresses/add_address_page.dart';
|
||||||
import '../../features/notifications/notifications_screen.dart';
|
import '../../features/notifications/notifications_screen.dart';
|
||||||
import '../../features/quiz/quiz_screen.dart';
|
import '../../features/quiz/quiz_screen.dart';
|
||||||
|
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<GoRouter>((ref) {
|
final routerProvider = Provider<GoRouter>((ref) {
|
||||||
final authState = ref.watch(authControllerProvider);
|
final authState = ref.watch(authControllerProvider);
|
||||||
@@ -143,6 +146,15 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
builder: (context, state) => const NotificationsScreen(),
|
builder: (context, state) => const NotificationsScreen(),
|
||||||
),
|
),
|
||||||
GoRoute(path: '/quiz', builder: (context, state) => const QuizScreen()),
|
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()),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
109
recolecta_app/lib/features/about/screens/about_screen.dart
Normal file
109
recolecta_app/lib/features/about/screens/about_screen.dart
Normal file
@@ -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<PackageInfo>(
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -141,23 +141,35 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
child: AnimatedSwitcher(
|
child: AnimatedSwitcher(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
child: loading
|
child: loading
|
||||||
? const SizedBox(
|
? const FittedBox(
|
||||||
key: ValueKey('loading'),
|
key: ValueKey('loading'),
|
||||||
width: 20,
|
fit: BoxFit.scaleDown,
|
||||||
height: 20,
|
child: Row(
|
||||||
child: CircularProgressIndicator(
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
strokeWidth: 2,
|
children: [
|
||||||
color: Colors.white,
|
VideoMascot(size: 34, zoom: 1.5),
|
||||||
),
|
SizedBox(width: 12),
|
||||||
)
|
Text(
|
||||||
: const Text(
|
'Saludando a Eco...',
|
||||||
'Ingresar',
|
|
||||||
key: ValueKey('text'),
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const FittedBox(
|
||||||
|
key: ValueKey('text'),
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: Text(
|
||||||
|
'Ingresar',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
// 5. FCM badge
|
// 5. FCM badge
|
||||||
// 6. Horario semanal
|
// 6. Horario semanal
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
@@ -87,11 +88,20 @@ class _EtaResult {
|
|||||||
// Provider de ETA
|
// Provider de ETA
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
class _EtaNotifier extends AsyncNotifier<_EtaResult> {
|
class _EtaNotifier extends AsyncNotifier<_EtaResult> {
|
||||||
|
Timer? _timer;
|
||||||
|
|
||||||
@override
|
@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<void> refresh() async {
|
Future<void> refresh() async {
|
||||||
state = const AsyncValue.loading();
|
// Eliminamos el estado "loading" explícito para evitar que la UI parpadee
|
||||||
state = await AsyncValue.guard(_fetch);
|
state = await AsyncValue.guard(_fetch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
39
recolecta_app/lib/features/feedback/feedback_model.dart
Normal file
39
recolecta_app/lib/features/feedback/feedback_model.dart
Normal file
@@ -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<String, dynamic> 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_*
|
||||||
|
};
|
||||||
|
}
|
||||||
21
recolecta_app/lib/features/help/data/help_chat_service.dart
Normal file
21
recolecta_app/lib/features/help/data/help_chat_service.dart
Normal file
@@ -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<HelpChatService>((ref) {
|
||||||
|
return HelpChatService(ref.read(apiClientProvider));
|
||||||
|
});
|
||||||
|
|
||||||
|
class HelpChatService {
|
||||||
|
HelpChatService(this._dio);
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
Future<String> ask(List<Map<String, String>> messages) async {
|
||||||
|
final res = await _dio.post<Map<String, dynamic>>(
|
||||||
|
'/chat/help',
|
||||||
|
data: {'messages': messages},
|
||||||
|
);
|
||||||
|
return (res.data?['reply'] as String?) ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String> toJson() => {'role': role, 'content': content};
|
||||||
|
}
|
||||||
|
|
||||||
|
class HelpChatState {
|
||||||
|
final List<HelpChatMessage> messages;
|
||||||
|
final bool sending;
|
||||||
|
final String? error;
|
||||||
|
|
||||||
|
const HelpChatState({
|
||||||
|
this.messages = const [],
|
||||||
|
this.sending = false,
|
||||||
|
this.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
HelpChatState copyWith({
|
||||||
|
List<HelpChatMessage>? 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<HelpChatState> {
|
||||||
|
@override
|
||||||
|
HelpChatState build() => const HelpChatState();
|
||||||
|
|
||||||
|
Future<void> 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, HelpChatState>(HelpChatController.new);
|
||||||
225
recolecta_app/lib/features/help/screens/help_faq_screen.dart
Normal file
225
recolecta_app/lib/features/help/screens/help_faq_screen.dart
Normal file
@@ -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<HelpFaqScreen> createState() => _HelpFaqScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HelpFaqScreenState extends ConsumerState<HelpFaqScreen> {
|
||||||
|
final _inputCtrl = TextEditingController();
|
||||||
|
final _scrollCtrl = ScrollController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_inputCtrl.dispose();
|
||||||
|
_scrollCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<String> 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<String> 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../core/widgets/app_widgets.dart';
|
import '../../core/widgets/app_widgets.dart';
|
||||||
|
import '../../shared/widgets/eco_floating_button.dart';
|
||||||
|
|
||||||
class CitizenShell extends StatelessWidget {
|
class CitizenShell extends StatelessWidget {
|
||||||
const CitizenShell({super.key, required this.child});
|
const CitizenShell({super.key, required this.child});
|
||||||
@@ -32,6 +33,7 @@ class CitizenShell extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: child,
|
body: child,
|
||||||
|
floatingActionButton: const EcoFloatingButton(),
|
||||||
bottomNavigationBar: AppBottomNav(
|
bottomNavigationBar: AppBottomNav(
|
||||||
currentIndex: _currentIndex(context),
|
currentIndex: _currentIndex(context),
|
||||||
onTap: (i) => _onTap(context, i),
|
onTap: (i) => _onTap(context, i),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../core/widgets/app_widgets.dart';
|
import '../../core/widgets/app_widgets.dart';
|
||||||
import '../eta/eta_screen.dart';
|
import 'citizen_home_screen.dart';
|
||||||
import '../alerts/alerts_screen.dart';
|
import '../alerts/alerts_screen.dart';
|
||||||
import 'house_screen.dart';
|
import 'house_screen.dart';
|
||||||
import '../profile/profile_screen.dart';
|
import '../profile/profile_screen.dart';
|
||||||
@@ -16,7 +16,7 @@ class _MainShellState extends State<MainShell> {
|
|||||||
int _currentIndex = 0;
|
int _currentIndex = 0;
|
||||||
|
|
||||||
static const List<Widget> _screens = [
|
static const List<Widget> _screens = [
|
||||||
EtaScreen(),
|
CitizenHomeScreen(),
|
||||||
AlertsScreen(),
|
AlertsScreen(),
|
||||||
MyHouseScreen(),
|
MyHouseScreen(),
|
||||||
ProfileScreen(),
|
ProfileScreen(),
|
||||||
|
|||||||
@@ -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<IncidentService>((ref) {
|
||||||
|
return IncidentService(ref.read(apiClientProvider));
|
||||||
|
});
|
||||||
|
|
||||||
|
class IncidentService {
|
||||||
|
IncidentService(this._dio);
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
Future<List<UnitOption>> listUnits() async {
|
||||||
|
final res = await _dio.get<List<dynamic>>('/incidents/units');
|
||||||
|
return (res.data ?? [])
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => UnitOption.fromJson(Map<String, dynamic>.from(e)))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<IncidentReport> 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<Map<String, dynamic>>(
|
||||||
|
'/incidents',
|
||||||
|
data: form,
|
||||||
|
options: Options(contentType: 'multipart/form-data'),
|
||||||
|
);
|
||||||
|
return IncidentReport.fromJson(res.data!);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<IncidentReport>> myIncidents() async {
|
||||||
|
final res = await _dio.get<List<dynamic>>('/incidents/me');
|
||||||
|
return (res.data ?? [])
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => IncidentReport.fromJson(Map<String, dynamic>.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
recolecta_app/lib/features/incidents/models/incident.dart
Normal file
56
recolecta_app/lib/features/incidents/models/incident.dart
Normal file
@@ -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<String, dynamic> 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<String, dynamic> 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 = <String, String>{
|
||||||
|
'derrame': 'Derrame de residuos',
|
||||||
|
'dano_propiedad': 'Daño a propiedad',
|
||||||
|
'conducta': 'Conducta inadecuada',
|
||||||
|
'no_recoleccion': 'No pasó el camión',
|
||||||
|
'otro': 'Otro',
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../data/incident_service.dart';
|
||||||
|
import '../models/incident.dart';
|
||||||
|
|
||||||
|
final unitsProvider = FutureProvider<List<UnitOption>>((ref) async {
|
||||||
|
return ref.read(incidentServiceProvider).listUnits();
|
||||||
|
});
|
||||||
|
|
||||||
|
final myIncidentsProvider = FutureProvider<List<IncidentReport>>((ref) async {
|
||||||
|
return ref.read(incidentServiceProvider).myIncidents();
|
||||||
|
});
|
||||||
@@ -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<ReportIssueScreen> createState() => _ReportIssueScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReportIssueScreenState extends ConsumerState<ReportIssueScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _descCtrl = TextEditingController();
|
||||||
|
|
||||||
|
int? _unitId;
|
||||||
|
String _category = 'no_recoleccion';
|
||||||
|
File? _photo;
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_descCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _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<int?>(
|
||||||
|
initialValue: _unitId,
|
||||||
|
isExpanded: true,
|
||||||
|
items: [
|
||||||
|
const DropdownMenuItem<int?>(
|
||||||
|
value: null,
|
||||||
|
child: Text('Sin unidad'),
|
||||||
|
),
|
||||||
|
for (final u in units)
|
||||||
|
DropdownMenuItem<int?>(
|
||||||
|
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<String>(
|
||||||
|
initialValue: _category,
|
||||||
|
isExpanded: true,
|
||||||
|
items: [
|
||||||
|
for (final entry in incidentCategories.entries)
|
||||||
|
DropdownMenuItem<String>(
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
recolecta_app/lib/features/profile/data/profile_service.dart
Normal file
38
recolecta_app/lib/features/profile/data/profile_service.dart
Normal file
@@ -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<ProfileService>((ref) {
|
||||||
|
return ProfileService(ref.read(apiClientProvider));
|
||||||
|
});
|
||||||
|
|
||||||
|
class ProfileService {
|
||||||
|
ProfileService(this._dio);
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
Future<ProfileUser> getMe() async {
|
||||||
|
final res = await _dio.get<Map<String, dynamic>>('/users/me');
|
||||||
|
return ProfileUser.fromJson(res.data!);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateMe({String? name, String? email, String? phone}) async {
|
||||||
|
final payload = <String, dynamic>{};
|
||||||
|
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<void>('/users/me', data: payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> changePassword({
|
||||||
|
required String currentPassword,
|
||||||
|
required String newPassword,
|
||||||
|
}) async {
|
||||||
|
await _dio.post<void>(
|
||||||
|
'/users/me/change-password',
|
||||||
|
data: {'current_password': currentPassword, 'new_password': newPassword},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,125 +1,409 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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 '../../core/theme/app_theme.dart';
|
||||||
import 'package:recolecta_app/core/services/auth_controller.dart';
|
import '../../core/widgets/app_widgets.dart';
|
||||||
import 'package:recolecta_app/core/api/api_service.dart';
|
import 'data/profile_service.dart';
|
||||||
|
import 'providers/profile_providers.dart';
|
||||||
|
|
||||||
class EditProfileScreen extends ConsumerStatefulWidget {
|
class EditProfileScreen extends ConsumerStatefulWidget {
|
||||||
const EditProfileScreen({super.key});
|
const EditProfileScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<ConsumerStatefulWidget> createState() =>
|
ConsumerState<EditProfileScreen> createState() => _EditProfileScreenState();
|
||||||
_EditProfileScreenState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EditProfileScreenState extends ConsumerState<EditProfileScreen> {
|
class _EditProfileScreenState extends ConsumerState<EditProfileScreen> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final _nameController = TextEditingController();
|
|
||||||
final _emailController = TextEditingController();
|
|
||||||
bool _isLoading = false;
|
|
||||||
|
|
||||||
@override
|
final _nameCtrl = TextEditingController();
|
||||||
void initState() {
|
final _emailCtrl = TextEditingController();
|
||||||
super.initState();
|
final _phoneCtrl = TextEditingController();
|
||||||
// TODO: Si deseas pre-llenar los datos, aquí puedes llamar a tu API
|
|
||||||
// (ej. GET /users/me) usando ref.read(apiServiceProvider)
|
final _currentPasswordCtrl = TextEditingController();
|
||||||
}
|
final _newPasswordCtrl = TextEditingController();
|
||||||
|
final _confirmPasswordCtrl = TextEditingController();
|
||||||
|
|
||||||
|
bool _saving = false;
|
||||||
|
bool _prefilled = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_nameController.dispose();
|
_nameCtrl.dispose();
|
||||||
_emailController.dispose();
|
_emailCtrl.dispose();
|
||||||
|
_phoneCtrl.dispose();
|
||||||
|
_currentPasswordCtrl.dispose();
|
||||||
|
_newPasswordCtrl.dispose();
|
||||||
|
_confirmPasswordCtrl.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveProfile() async {
|
void _prefill(Map<String, String?> data) {
|
||||||
if (!_formKey.currentState!.validate()) {
|
if (_prefilled) return;
|
||||||
return;
|
_nameCtrl.text = data['name'] ?? '';
|
||||||
|
_emailCtrl.text = data['email'] ?? '';
|
||||||
|
_phoneCtrl.text = _formatPhoneInitial(data['phone']);
|
||||||
|
_prefilled = true;
|
||||||
}
|
}
|
||||||
setState(() {
|
|
||||||
_isLoading = 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)}';
|
||||||
|
}
|
||||||
|
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<void> _save() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
setState(() => _saving = true);
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
try {
|
try {
|
||||||
final apiService = ref.read(apiServiceProvider);
|
await ref
|
||||||
await apiService.updateUser({
|
.read(profileServiceProvider)
|
||||||
'name': _nameController.text,
|
.updateMe(
|
||||||
'email': _emailController.text,
|
name: _nameCtrl.text.trim(),
|
||||||
});
|
email: _emailCtrl.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: _emailCtrl.text.trim(),
|
||||||
|
phone: _phoneCtrl.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: _phoneCtrl.text.trim(),
|
||||||
|
);
|
||||||
|
|
||||||
if (mounted) {
|
if (_wantsPasswordChange) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
await ref
|
||||||
const SnackBar(content: Text('Perfil actualizado con éxito')),
|
.read(profileServiceProvider)
|
||||||
|
.changePassword(
|
||||||
|
currentPassword: _currentPasswordCtrl.text,
|
||||||
|
newPassword: _newPasswordCtrl.text,
|
||||||
);
|
);
|
||||||
Navigator.of(context).pop();
|
_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) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
messenger.showSnackBar(SnackBar(content: Text('Error: ${_friendly(e)}')));
|
||||||
SnackBar(content: Text('Error al actualizar el perfil: $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) setState(() => _saving = false);
|
||||||
setState(() {
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final userAsync = ref.watch(currentUserProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
backgroundColor: AppTheme.background,
|
||||||
title: const Text('Editar Perfil'),
|
appBar: AppBar(title: const Text('Editar perfil')),
|
||||||
actions: [
|
body: userAsync.when(
|
||||||
if (_isLoading)
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
const Padding(
|
error: (e, _) => Center(
|
||||||
padding: EdgeInsets.only(right: 16.0),
|
child: Padding(
|
||||||
child: CircularProgressIndicator(),
|
padding: const EdgeInsets.all(24),
|
||||||
)
|
child: Text(
|
||||||
else
|
'No se pudo cargar el perfil:\n${_friendly(e)}',
|
||||||
TextButton(onPressed: _saveProfile, child: const Text('Guardar')),
|
textAlign: TextAlign.center,
|
||||||
],
|
style: const TextStyle(color: AppTheme.danger),
|
||||||
),
|
),
|
||||||
body: Form(
|
),
|
||||||
|
),
|
||||||
|
data: (user) {
|
||||||
|
_prefill({
|
||||||
|
'name': user.name,
|
||||||
|
'email': user.email,
|
||||||
|
'phone': user.phone,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
TextFormField(
|
const AppSectionTitle(title: 'Datos personales'),
|
||||||
controller: _nameController,
|
AppCard(
|
||||||
decoration: const InputDecoration(
|
child: Column(
|
||||||
labelText: 'Nombre',
|
children: [
|
||||||
border: OutlineInputBorder(),
|
AppFormField(
|
||||||
),
|
label: 'Nombre',
|
||||||
validator: (value) {
|
controller: _nameCtrl,
|
||||||
if (value == null || value.isEmpty) {
|
validator: (v) => (v == null || v.trim().isEmpty)
|
||||||
return 'Por favor ingresa tu nombre';
|
? 'Ingresa tu nombre'
|
||||||
}
|
: null,
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
TextFormField(
|
|
||||||
controller: _emailController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Correo Electrónico',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
AppFormField(
|
||||||
|
label: 'Correo electrónico',
|
||||||
|
controller: _emailCtrl,
|
||||||
keyboardType: TextInputType.emailAddress,
|
keyboardType: TextInputType.emailAddress,
|
||||||
validator: (value) {
|
validator: (v) {
|
||||||
if (value == null || !value.contains('@')) {
|
if (v == null || v.trim().isEmpty) return null;
|
||||||
return 'Por favor ingresa un correo válido';
|
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;
|
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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
43
recolecta_app/lib/features/profile/models/profile_user.dart
Normal file
43
recolecta_app/lib/features/profile/models/profile_user.dart
Normal file
@@ -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<String, dynamic> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,71 +5,45 @@ import 'package:go_router/go_router.dart';
|
|||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../core/widgets/app_widgets.dart';
|
import '../../core/widgets/app_widgets.dart';
|
||||||
import '../../core/services/auth_controller.dart';
|
import '../../core/services/auth_controller.dart';
|
||||||
import '../../core/storage/secure_storage.dart';
|
|
||||||
import '../../core/constants/auth_constants.dart';
|
|
||||||
import '../separation_guide/ai_pet_chat_screen.dart';
|
import '../separation_guide/ai_pet_chat_screen.dart';
|
||||||
|
import 'models/profile_user.dart';
|
||||||
|
import 'providers/profile_providers.dart';
|
||||||
|
|
||||||
class ProfileScreen extends ConsumerWidget {
|
class ProfileScreen extends ConsumerWidget {
|
||||||
const ProfileScreen({super.key});
|
const ProfileScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final authState = ref.watch(authControllerProvider).asData?.value;
|
final authRole =
|
||||||
final storage = ref.read(secureStorageProvider);
|
ref.watch(authControllerProvider).asData?.value.userRole ?? 'citizen';
|
||||||
|
final userAsync = ref.watch(currentUserProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppTheme.background,
|
backgroundColor: AppTheme.background,
|
||||||
appBar: AppBar(title: const Text('Mi perfil')),
|
appBar: AppBar(title: const Text('Mi perfil')),
|
||||||
body: FutureBuilder<_ProfileData>(
|
body: RefreshIndicator(
|
||||||
future: _loadProfile(storage),
|
onRefresh: () async {
|
||||||
builder: (context, snapshot) {
|
ref.invalidate(currentUserProvider);
|
||||||
final profile =
|
await ref.read(currentUserProvider.future);
|
||||||
snapshot.data ??
|
},
|
||||||
_ProfileData(
|
child: ListView(
|
||||||
email: authState?.token != null ? '…' : '',
|
|
||||||
role: authState?.userRole ?? 'citizen',
|
|
||||||
);
|
|
||||||
|
|
||||||
return ListView(
|
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
_ProfileHeader(profile: profile),
|
_ProfileHeader(
|
||||||
|
user: userAsync.asData?.value,
|
||||||
|
fallbackRole: authRole,
|
||||||
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
const AppSectionTitle(title: 'Mi cuenta'),
|
const AppSectionTitle(title: 'Mi cuenta'),
|
||||||
AppMenuTile(
|
AppMenuTile(
|
||||||
icon: Icons.person_outline,
|
icon: Icons.person_outline,
|
||||||
title: 'Editar perfil',
|
title: 'Editar perfil',
|
||||||
subtitle: profile.email,
|
subtitle: 'Nombre, correo, teléfono y contraseña',
|
||||||
onTap: () => context.go('/edit-profile'),
|
onTap: () => context.push('/edit-profile'),
|
||||||
),
|
),
|
||||||
AppMenuTile(
|
if ((userAsync.asData?.value.isAdmin ?? false) ||
|
||||||
icon: Icons.lock_outline,
|
authRole == 'admin')
|
||||||
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(
|
AppMenuTile(
|
||||||
icon: Icons.admin_panel_settings_outlined,
|
icon: Icons.admin_panel_settings_outlined,
|
||||||
title: 'Panel de administración',
|
title: 'Panel de administración',
|
||||||
@@ -93,18 +67,19 @@ class ProfileScreen extends ConsumerWidget {
|
|||||||
AppMenuTile(
|
AppMenuTile(
|
||||||
icon: Icons.help_outline,
|
icon: Icons.help_outline,
|
||||||
title: 'Ayuda y preguntas frecuentes',
|
title: 'Ayuda y preguntas frecuentes',
|
||||||
onTap: () {},
|
subtitle: 'Chatea con nuestro asistente',
|
||||||
|
onTap: () => context.push('/help'),
|
||||||
),
|
),
|
||||||
AppMenuTile(
|
AppMenuTile(
|
||||||
icon: Icons.bug_report_outlined,
|
icon: Icons.bug_report_outlined,
|
||||||
title: 'Reportar un problema',
|
title: 'Reportar un problema',
|
||||||
onTap: () {},
|
subtitle: 'Reporta una unidad o incidente',
|
||||||
|
onTap: () => context.push('/report-issue'),
|
||||||
),
|
),
|
||||||
AppMenuTile(
|
AppMenuTile(
|
||||||
icon: Icons.info_outline,
|
icon: Icons.info_outline,
|
||||||
title: 'Acerca de la app',
|
title: 'Acerca de la app',
|
||||||
subtitle: 'Versión 1.0.0',
|
onTap: () => context.push('/about'),
|
||||||
onTap: () {},
|
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@@ -131,18 +106,11 @@ class ProfileScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
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) {
|
void _confirmarCerrarSesion(BuildContext context, WidgetRef ref) {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
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 ────────────────────────────────────────────────────────────────
|
// ── Encabezado ────────────────────────────────────────────────────────────────
|
||||||
class _ProfileHeader extends StatelessWidget {
|
class _ProfileHeader extends StatelessWidget {
|
||||||
final _ProfileData profile;
|
final ProfileUser? user;
|
||||||
const _ProfileHeader({required this.profile});
|
final String fallbackRole;
|
||||||
|
const _ProfileHeader({required this.user, required this.fallbackRole});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -229,7 +192,7 @@ class _ProfileHeader extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
profile.iniciales,
|
initials,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
@@ -244,7 +207,7 @@ class _ProfileHeader extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
profile.displayName,
|
displayName,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
@@ -253,7 +216,7 @@ class _ProfileHeader extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
profile.email,
|
email,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: AppTheme.textSecondary,
|
color: AppTheme.textSecondary,
|
||||||
@@ -261,7 +224,11 @@ class _ProfileHeader extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
AppStatusBadge.green(
|
AppStatusBadge.green(
|
||||||
profile.isAdmin ? 'Administrador' : 'Ciudadano',
|
isAdmin
|
||||||
|
? 'Administrador'
|
||||||
|
: isDriver
|
||||||
|
? 'Chofer'
|
||||||
|
: 'Ciudadano',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -271,3 +238,4 @@ class _ProfileHeader extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../data/profile_service.dart';
|
||||||
|
import '../models/profile_user.dart';
|
||||||
|
|
||||||
|
final currentUserProvider = FutureProvider<ProfileUser>((ref) async {
|
||||||
|
return ref.read(profileServiceProvider).getMe();
|
||||||
|
});
|
||||||
76
recolecta_app/lib/shared/widgets/eco_floating_button.dart
Normal file
76
recolecta_app/lib/shared/widgets/eco_floating_button.dart
Normal file
@@ -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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,13 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <file_selector_linux/file_selector_plugin.h>
|
||||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
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 =
|
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||||
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
file_selector_linux
|
||||||
flutter_secure_storage_linux
|
flutter_secure_storage_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,18 +5,22 @@
|
|||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import file_selector_macos
|
||||||
import firebase_core
|
import firebase_core
|
||||||
import firebase_messaging
|
import firebase_messaging
|
||||||
import flutter_local_notifications
|
import flutter_local_notifications
|
||||||
import flutter_secure_storage_macos
|
import flutter_secure_storage_macos
|
||||||
|
import package_info_plus
|
||||||
import sqflite_darwin
|
import sqflite_darwin
|
||||||
import video_player_avfoundation
|
import video_player_avfoundation
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||||
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
||||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||||
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
VideoPlayerPlugin.register(with: registry.registrar(forPlugin: "VideoPlayerPlugin"))
|
VideoPlayerPlugin.register(with: registry.registrar(forPlugin: "VideoPlayerPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.15.0"
|
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:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -201,6 +209,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
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:
|
firebase_core:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -318,6 +358,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.2.1"
|
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:
|
flutter_riverpod:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -441,13 +489,77 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.2"
|
version: "3.2.2"
|
||||||
http_parser:
|
http_parser:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: http_parser
|
name: http_parser
|
||||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
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:
|
intl:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -624,6 +736,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
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:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ dependencies:
|
|||||||
flutter_map: ^6.1.0
|
flutter_map: ^6.1.0
|
||||||
latlong2: ^0.9.0
|
latlong2: ^0.9.0
|
||||||
video_player: ^2.9.2
|
video_player: ^2.9.2
|
||||||
|
package_info_plus: ^8.0.0
|
||||||
|
image_picker: ^1.1.2
|
||||||
|
http_parser: ^4.0.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -68,6 +71,7 @@ flutter:
|
|||||||
- assets/.env
|
- assets/.env
|
||||||
- assets/data/separation_guide.json
|
- assets/data/separation_guide.json
|
||||||
- assets/animations/blink_saludo.gif
|
- assets/animations/blink_saludo.gif
|
||||||
|
- assets/animations/info.gif
|
||||||
- assets/animations/
|
- assets/animations/
|
||||||
# The following line ensures that the Material Icons font is
|
# The following line ensures that the Material Icons font is
|
||||||
# included with your application, so that you can use the icons in
|
# included with your application, so that you can use the icons in
|
||||||
|
|||||||
@@ -6,10 +6,13 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <file_selector_windows/file_selector_windows.h>
|
||||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
|
FileSelectorWindowsRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
file_selector_windows
|
||||||
firebase_core
|
firebase_core
|
||||||
flutter_secure_storage_windows
|
flutter_secure_storage_windows
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user