simulacion de estados y flujo de notificacion, modificacion de estilos en todas las vistas
This commit is contained in:
@@ -96,3 +96,61 @@ def get_address(
|
|||||||
raise HTTPException(status_code=403, detail="No tienes acceso a este domicilio")
|
raise HTTPException(status_code=403, detail="No tienes acceso a este domicilio")
|
||||||
|
|
||||||
return address
|
return address
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{address_id}/unit")
|
||||||
|
def get_address_unit(
|
||||||
|
address_id: str,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Devuelve la unidad asignada al domicilio (a través de su ruta).
|
||||||
|
Cadena: addresses.route_id → routes.truck_id → units.
|
||||||
|
El ciudadano sólo puede consultar sus propios domicilios.
|
||||||
|
"""
|
||||||
|
addr_res = (
|
||||||
|
supabase_admin.table("addresses")
|
||||||
|
.select("id, user_id, route_id")
|
||||||
|
.eq("id", address_id)
|
||||||
|
.maybe_single()
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
if not addr_res.data:
|
||||||
|
raise HTTPException(status_code=404, detail="Domicilio no encontrado")
|
||||||
|
|
||||||
|
address = addr_res.data
|
||||||
|
if current_user["role"] != "admin" and address["user_id"] != current_user["user_id"]:
|
||||||
|
raise HTTPException(status_code=403, detail="No tienes acceso a este domicilio")
|
||||||
|
|
||||||
|
route_id = address.get("route_id")
|
||||||
|
if not route_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="El domicilio no tiene una ruta asociada",
|
||||||
|
)
|
||||||
|
|
||||||
|
route_res = (
|
||||||
|
supabase_admin.table("routes")
|
||||||
|
.select("id, truck_id")
|
||||||
|
.eq("id", route_id)
|
||||||
|
.maybe_single()
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
if not route_res.data or route_res.data.get("truck_id") is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="La ruta asignada no tiene unidad activa",
|
||||||
|
)
|
||||||
|
|
||||||
|
truck_id = route_res.data["truck_id"]
|
||||||
|
unit_res = (
|
||||||
|
supabase_admin.table("units")
|
||||||
|
.select("id, plate, status")
|
||||||
|
.eq("id", truck_id)
|
||||||
|
.maybe_single()
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
if not unit_res.data:
|
||||||
|
raise HTTPException(status_code=404, detail="Unidad no encontrada")
|
||||||
|
|
||||||
|
return unit_res.data
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Operan directamente contra Supabase (RLS bypaseado por service_role).
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from app.core.deps import require_role
|
from app.core.deps import get_current_user, require_role
|
||||||
from app.core.supabase_client import supabase_admin
|
from app.core.supabase_client import supabase_admin
|
||||||
from app.schemas.admin import (
|
from app.schemas.admin import (
|
||||||
AdminUser,
|
AdminUser,
|
||||||
@@ -375,3 +375,156 @@ def delete_driver(driver_id: str):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(400, f"Error al borrar el conductor: {e}")
|
raise HTTPException(400, f"Error al borrar el conductor: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Incidents por unidad ──────────────────────────────────────────────────────
|
||||||
|
_INCIDENT_CATEGORIES = {
|
||||||
|
"derrame",
|
||||||
|
"dano_propiedad",
|
||||||
|
"conducta",
|
||||||
|
"no_recoleccion",
|
||||||
|
"otro",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _route_for_unit(unit_id: int) -> Optional[str]:
|
||||||
|
"""Devuelve el route_id que actualmente tiene asignada la unidad
|
||||||
|
(vía routes.truck_id), o None si no está asignada a ninguna ruta."""
|
||||||
|
try:
|
||||||
|
res = (
|
||||||
|
supabase_admin.table("routes")
|
||||||
|
.select("id")
|
||||||
|
.eq("truck_id", unit_id)
|
||||||
|
.limit(1)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
rows = res.data or []
|
||||||
|
return rows[0]["id"] if rows else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _driver_for_unit(unit_id: int, users_map: dict[str, dict]) -> Optional[str]:
|
||||||
|
"""Devuelve el nombre del chofer asignado a la unidad (drivers.unit_id)."""
|
||||||
|
try:
|
||||||
|
res = (
|
||||||
|
supabase_admin.table("drivers")
|
||||||
|
.select("user_id")
|
||||||
|
.eq("unit_id", unit_id)
|
||||||
|
.limit(1)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
rows = res.data or []
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
user_id = str(rows[0].get("user_id"))
|
||||||
|
return (users_map.get(user_id) or {}).get("name")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_incident(row: dict, route_id: Optional[str], driver_name: Optional[str]) -> dict:
|
||||||
|
"""Da la forma que espera el cliente Flutter (admin_incident.dart)."""
|
||||||
|
return {
|
||||||
|
"id": str(row.get("id")),
|
||||||
|
"unit_id": row.get("unit_id"),
|
||||||
|
"route_id": route_id,
|
||||||
|
"type": row.get("category"),
|
||||||
|
"description": row.get("description"),
|
||||||
|
"driver_name": driver_name,
|
||||||
|
"status": row.get("status") or "open",
|
||||||
|
"photo_url": row.get("photo_url"),
|
||||||
|
"created_at": row.get("created_at") and str(row["created_at"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/units/{unit_id}/incidents")
|
||||||
|
def list_unit_incidents(unit_id: int):
|
||||||
|
"""Reportes ciudadanos asociados a esta unidad, más reciente primero."""
|
||||||
|
# Verifica que la unidad exista (devuelve 404 si no)
|
||||||
|
unit_res = (
|
||||||
|
supabase_admin.table("units")
|
||||||
|
.select("id")
|
||||||
|
.eq("id", unit_id)
|
||||||
|
.maybe_single()
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
if not unit_res.data:
|
||||||
|
raise HTTPException(404, "Unidad no encontrada")
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = (
|
||||||
|
supabase_admin.table("incidents")
|
||||||
|
.select("id, unit_id, user_id, category, description, status, photo_url, created_at")
|
||||||
|
.eq("unit_id", unit_id)
|
||||||
|
.order("created_at", desc=True)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, f"Error al listar incidencias: {e}")
|
||||||
|
|
||||||
|
rows = res.data or []
|
||||||
|
route_id = _route_for_unit(unit_id)
|
||||||
|
|
||||||
|
# Pre-cargar el mapa de nombres una sola vez
|
||||||
|
users_res = supabase_admin.table("users").select("id, name").execute()
|
||||||
|
users_map: dict[str, dict] = {
|
||||||
|
str(u["id"]): {"name": u.get("name")} for u in (users_res.data or [])
|
||||||
|
}
|
||||||
|
driver_name = _driver_for_unit(unit_id, users_map)
|
||||||
|
|
||||||
|
return [_serialize_incident(r, route_id, driver_name) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/units/{unit_id}/incidents", status_code=201)
|
||||||
|
def create_unit_incident(
|
||||||
|
unit_id: int,
|
||||||
|
body: dict,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Crea una incidencia para esta unidad. El admin queda como reporter."""
|
||||||
|
category = (body.get("type") or body.get("category") or "").strip()
|
||||||
|
description = (body.get("description") or "").strip()
|
||||||
|
|
||||||
|
if category not in _INCIDENT_CATEGORIES:
|
||||||
|
raise HTTPException(
|
||||||
|
400,
|
||||||
|
f"Categoría inválida. Use una de: {sorted(_INCIDENT_CATEGORIES)}",
|
||||||
|
)
|
||||||
|
if len(description) < 3:
|
||||||
|
# La tabla requiere description NOT NULL y un mínimo razonable.
|
||||||
|
description = description or category
|
||||||
|
|
||||||
|
# La unidad debe existir
|
||||||
|
unit_res = (
|
||||||
|
supabase_admin.table("units")
|
||||||
|
.select("id")
|
||||||
|
.eq("id", unit_id)
|
||||||
|
.maybe_single()
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
if not unit_res.data:
|
||||||
|
raise HTTPException(404, "Unidad no encontrada")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"user_id": current_user["user_id"],
|
||||||
|
"unit_id": unit_id,
|
||||||
|
"category": category,
|
||||||
|
"description": description,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
res = supabase_admin.table("incidents").insert(payload).execute()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, f"Error al crear la incidencia: {e}")
|
||||||
|
|
||||||
|
row = (res.data or [None])[0]
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(500, "Supabase no devolvió la fila creada")
|
||||||
|
|
||||||
|
route_id = _route_for_unit(unit_id)
|
||||||
|
users_res = supabase_admin.table("users").select("id, name").execute()
|
||||||
|
users_map: dict[str, dict] = {
|
||||||
|
str(u["id"]): {"name": u.get("name")} for u in (users_res.data or [])
|
||||||
|
}
|
||||||
|
driver_name = _driver_for_unit(unit_id, users_map)
|
||||||
|
return _serialize_incident(row, route_id, driver_name)
|
||||||
|
|||||||
93
backend/app/api/feedback.py
Normal file
93
backend/app/api/feedback.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""Endpoint de retroalimentación ciudadana.
|
||||||
|
|
||||||
|
Reglas:
|
||||||
|
- El user_id se toma del token (nunca del body).
|
||||||
|
- target_unit_id es la UNIDAD (camión), no el chofer.
|
||||||
|
- La inserción usa supabase_admin (service_role) que bypassea RLS.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from app.core.deps import get_current_user
|
||||||
|
from app.core.supabase_client import supabase_admin
|
||||||
|
from app.schemas.feedback import FeedbackCreate, FeedbackOut
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/feedback", tags=["feedback"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=FeedbackOut, status_code=201)
|
||||||
|
def create_feedback(
|
||||||
|
body: FeedbackCreate,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
user_id = current_user["user_id"]
|
||||||
|
|
||||||
|
# Validar que la unidad exista si se envió
|
||||||
|
if body.target_unit_id is not None:
|
||||||
|
try:
|
||||||
|
unit = (
|
||||||
|
supabase_admin.table("units")
|
||||||
|
.select("id")
|
||||||
|
.eq("id", body.target_unit_id)
|
||||||
|
.limit(1)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error al validar unidad: {e}")
|
||||||
|
if not unit.data:
|
||||||
|
raise HTTPException(status_code=400, detail="Unidad inexistente")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"address_id": body.address_id,
|
||||||
|
"type": body.type,
|
||||||
|
"target_unit_id": body.target_unit_id,
|
||||||
|
"message": body.message,
|
||||||
|
"rating": body.rating,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = supabase_admin.table("feedback").insert(payload).execute()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error al guardar feedback: {e}")
|
||||||
|
|
||||||
|
row = (res.data or [{}])[0]
|
||||||
|
return FeedbackOut(
|
||||||
|
id=str(row.get("id")),
|
||||||
|
user_id=str(row.get("user_id")),
|
||||||
|
address_id=row.get("address_id") and str(row["address_id"]),
|
||||||
|
type=row.get("type") or body.type,
|
||||||
|
target_unit_id=row.get("target_unit_id"),
|
||||||
|
message=row.get("message"),
|
||||||
|
rating=row.get("rating"),
|
||||||
|
created_at=row.get("created_at") and str(row["created_at"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=list[FeedbackOut])
|
||||||
|
def my_feedback(current_user: dict = Depends(get_current_user)):
|
||||||
|
try:
|
||||||
|
res = (
|
||||||
|
supabase_admin.table("feedback")
|
||||||
|
.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 feedback: {e}")
|
||||||
|
|
||||||
|
out: list[FeedbackOut] = []
|
||||||
|
for row in res.data or []:
|
||||||
|
out.append(
|
||||||
|
FeedbackOut(
|
||||||
|
id=str(row.get("id")),
|
||||||
|
user_id=str(row.get("user_id")),
|
||||||
|
address_id=row.get("address_id") and str(row["address_id"]),
|
||||||
|
type=row.get("type") or "",
|
||||||
|
target_unit_id=row.get("target_unit_id"),
|
||||||
|
message=row.get("message"),
|
||||||
|
rating=row.get("rating"),
|
||||||
|
created_at=row.get("created_at") and str(row["created_at"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
28
backend/app/schemas/feedback.py
Normal file
28
backend/app/schemas/feedback.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""Esquemas Pydantic para retroalimentación ciudadana."""
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackCreate(BaseModel):
|
||||||
|
"""Cuerpo enviado por el ciudadano al crear una queja/sugerencia.
|
||||||
|
|
||||||
|
Importante: NUNCA se acepta driver_id; solo el número de la unidad.
|
||||||
|
"""
|
||||||
|
|
||||||
|
address_id: Optional[str] = None # UUID
|
||||||
|
type: str = Field(min_length=1, max_length=40)
|
||||||
|
rating: int = Field(ge=1, le=5)
|
||||||
|
# Llega como string ("101") desde Flutter; Pydantic lo coacciona a int.
|
||||||
|
target_unit_id: Optional[int] = None
|
||||||
|
message: Optional[str] = Field(default=None, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
address_id: Optional[str] = None
|
||||||
|
type: str
|
||||||
|
target_unit_id: Optional[int] = None
|
||||||
|
message: Optional[str] = None
|
||||||
|
rating: Optional[int] = None
|
||||||
|
created_at: Optional[str] = None
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import firebase_admin
|
import firebase_admin
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
from firebase_admin import credentials, messaging
|
from firebase_admin import credentials, messaging
|
||||||
|
|
||||||
_firebase_initialized = False
|
_firebase_initialized = False
|
||||||
@@ -27,9 +29,13 @@ def send_to_topic(topic: str, payload: dict):
|
|||||||
"""Envía una notificación push a todos los dispositivos suscritos a un topic (ej. RUTA-01)."""
|
"""Envía una notificación push a todos los dispositivos suscritos a un topic (ej. RUTA-01)."""
|
||||||
title = payload.get("title", "")
|
title = payload.get("title", "")
|
||||||
body = payload.get("body", "")
|
body = payload.get("body", "")
|
||||||
|
# `data` viaja como Dict[str, str] en FCM; permite que el cliente
|
||||||
|
# clasifique el evento (event, routeId) en notifications_screen.dart.
|
||||||
|
raw_data = payload.get("data") or {}
|
||||||
|
data: dict[str, str] = {str(k): str(v) for k, v in raw_data.items() if v is not None}
|
||||||
|
|
||||||
if not _firebase_initialized:
|
if not _firebase_initialized:
|
||||||
print(f"[MOCK PUSH] -> Topic: {topic} | Título: '{title}' | Mensaje: '{body}'")
|
print(f"[MOCK PUSH] -> Topic: {topic} | Título: '{title}' | Mensaje: '{body}' | Data: {data}")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -38,9 +44,29 @@ def send_to_topic(topic: str, payload: dict):
|
|||||||
title=title,
|
title=title,
|
||||||
body=body,
|
body=body,
|
||||||
),
|
),
|
||||||
|
data=data or None,
|
||||||
topic=topic,
|
topic=topic,
|
||||||
)
|
)
|
||||||
response = messaging.send(message)
|
response = messaging.send(message)
|
||||||
print(f"Push enviado al topic '{topic}' exitosamente. MessageID: {response}")
|
print(f"Push enviado al topic '{topic}' exitosamente. MessageID: {response}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error al enviar push al topic '{topic}': {e}")
|
print(f"Error al enviar push al topic '{topic}': {e}")
|
||||||
|
|
||||||
|
def send_whatsapp_alert(phone: str, message: str):
|
||||||
|
"""
|
||||||
|
Envía un WhatsApp usando la API gratuita de CallMeBot.
|
||||||
|
"""
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print(f"🟢 [WHATSAPP TRIGGER] Preparando mensaje para {phone}")
|
||||||
|
print(f"💬 Mensaje: {message}")
|
||||||
|
print("="*50 + "\n")
|
||||||
|
|
||||||
|
# REEMPLAZA ESTO POR EL CÓDIGO QUE TE DIO EL BOT EN WHATSAPP:
|
||||||
|
apikey = "6756808" # <--- BORRA "TU_API_KEY_AQUI" Y PON TU CÓDIGO REAL AQUÍ
|
||||||
|
safe_msg = urllib.parse.quote(message)
|
||||||
|
url = f"https://api.callmebot.com/whatsapp.php?phone={phone}&text={safe_msg}&apikey={apikey}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(url, timeout=5)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error enviando WhatsApp real: {e}")
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
from app.services import notifications
|
from app.services import notifications
|
||||||
|
|
||||||
@@ -58,14 +59,28 @@ def _find_notif(event_name: str) -> Optional[Dict]:
|
|||||||
|
|
||||||
|
|
||||||
def tick() -> List[Dict]:
|
def tick() -> List[Dict]:
|
||||||
"""Avanza todas las rutas en memoria (pos 1..8) y devuelve eventos disparados."""
|
"""Avanza todas las rutas en memoria (pos 1→8) y devuelve eventos disparados.
|
||||||
global ESTADO, LAST_EVENTS
|
Al llegar a pos 8, reinicia la ruta para que la demo sea un ciclo continuo."""
|
||||||
|
global ESTADO, STATUS, LAST_EVENTS
|
||||||
events = []
|
events = []
|
||||||
for route_id, pos in list(ESTADO.items()):
|
for route_id, pos in list(ESTADO.items()):
|
||||||
if pos < 8:
|
# Ciclo: cuando la ruta completó, reiniciar en el siguiente tick
|
||||||
|
if pos >= 8:
|
||||||
|
ESTADO[route_id] = 1
|
||||||
|
STATUS[route_id] = "PENDIENTE"
|
||||||
|
print(f"[SIM RESET] {route_id} reiniciado para nuevo ciclo.")
|
||||||
|
continue
|
||||||
|
|
||||||
antes = pos
|
antes = pos
|
||||||
ahora = pos + 1
|
ahora = pos + 1
|
||||||
ESTADO[route_id] = ahora
|
ESTADO[route_id] = ahora
|
||||||
|
|
||||||
|
# Actualizar STATUS según la nueva posición
|
||||||
|
if ahora == 2:
|
||||||
|
STATUS[route_id] = "en_ruta"
|
||||||
|
elif ahora == 8:
|
||||||
|
STATUS[route_id] = "completada"
|
||||||
|
|
||||||
evt = None
|
evt = None
|
||||||
if antes == 1 and ahora == 2:
|
if antes == 1 and ahora == 2:
|
||||||
evt = "ROUTE_START"
|
evt = "ROUTE_START"
|
||||||
@@ -77,13 +92,30 @@ def tick() -> List[Dict]:
|
|||||||
if evt:
|
if evt:
|
||||||
notif = _find_notif(evt)
|
notif = _find_notif(evt)
|
||||||
payload = notif.get("pushPayload") if notif else {"title": evt, "body": ""}
|
payload = notif.get("pushPayload") if notif else {"title": evt, "body": ""}
|
||||||
|
# Adjuntar event + routeId en `data` para que el cliente Flutter
|
||||||
|
# los clasifique (notifications_screen.dart usa msg.data['event']).
|
||||||
|
payload = {
|
||||||
|
**payload,
|
||||||
|
"data": {
|
||||||
|
**(payload.get("data") or {}),
|
||||||
|
"event": evt,
|
||||||
|
"routeId": route_id,
|
||||||
|
},
|
||||||
|
}
|
||||||
simulated = {"routeId": route_id, "event": evt, "payload": payload}
|
simulated = {"routeId": route_id, "event": evt, "payload": payload}
|
||||||
events.append(simulated)
|
events.append(simulated)
|
||||||
LAST_EVENTS.append(simulated)
|
LAST_EVENTS.append(simulated)
|
||||||
# Enviar push vía servicio de notificaciones (FCM) o mock
|
|
||||||
topic = f"topic_{route_id}"
|
topic = f"topic_{route_id}"
|
||||||
try:
|
try:
|
||||||
notifications.send_to_topic(topic, payload)
|
notifications.send_to_topic(topic, payload)
|
||||||
|
|
||||||
|
# ── WHATSAPP: Se dispara cuando el camión está cerca (posición 4) ──
|
||||||
|
if evt == "TRUCK_PROXIMITY":
|
||||||
|
# Para evitar saturar el bot y bloquear el servidor,
|
||||||
|
# enviamos el WhatsApp de demostración solo para la primera ruta.
|
||||||
|
if route_id == "RUTA-01":
|
||||||
|
msg = f"¡Hola! Soy Eco 🍃. El camión recolector de tu ruta ({route_id}) está a menos de 15 minutos. ¡Saca la basura! ♻️"
|
||||||
|
notifications.send_whatsapp_alert("5214131060699", msg)
|
||||||
except Exception:
|
except Exception:
|
||||||
print(f"[SIM PUSH FAIL] {route_id} -> {evt}: {payload.get('title')} - {payload.get('body')}")
|
print(f"[SIM PUSH FAIL] {route_id} -> {evt}: {payload.get('title')} - {payload.get('body')}")
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ 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.chat import router as chat_router
|
||||||
from app.api.incidents import router as incidents_router
|
from app.api.incidents import router as incidents_router
|
||||||
|
from app.api.feedback import router as feedback_router
|
||||||
from app.services import simulation, notifications
|
from app.services import simulation, notifications
|
||||||
|
|
||||||
scheduler = AsyncIOScheduler()
|
scheduler = AsyncIOScheduler()
|
||||||
@@ -67,3 +68,4 @@ app.include_router(admin_router)
|
|||||||
app.include_router(simulation_router)
|
app.include_router(simulation_router)
|
||||||
app.include_router(chat_router)
|
app.include_router(chat_router)
|
||||||
app.include_router(incidents_router)
|
app.include_router(incidents_router)
|
||||||
|
app.include_router(feedback_router)
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- Necesario para mostrar notificaciones en Android 13 (API 33) y superiores -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="recolecta_app"
|
android:label="recolecta_app"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
@@ -30,6 +33,17 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
|
||||||
|
<!-- Canal Android por defecto para los push de FCM cuando la app está
|
||||||
|
en background/terminated. Debe coincidir con el id usado en
|
||||||
|
lib/features/notifications/notification_service.dart -->
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||||
|
android:value="recolecta_alerts" />
|
||||||
|
<!-- Ícono por defecto para las notificaciones FCM (evita ícono blanco/cuadrado) -->
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||||
|
android:resource="@mipmap/ic_launcher" />
|
||||||
</application>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
|
|||||||
@@ -26,10 +26,16 @@ import '../../features/help/screens/help_faq_screen.dart';
|
|||||||
import '../../features/incidents/screens/report_issue_screen.dart';
|
import '../../features/incidents/screens/report_issue_screen.dart';
|
||||||
import '../../features/about/screens/about_screen.dart';
|
import '../../features/about/screens/about_screen.dart';
|
||||||
|
|
||||||
|
/// Clave global del Navigator raíz. La expone GoRouter y la usa
|
||||||
|
/// `NotificationService` para navegar a `/notifications` cuando el usuario
|
||||||
|
/// toca un push (foreground, background o terminated).
|
||||||
|
final rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||||
|
|
||||||
final routerProvider = Provider<GoRouter>((ref) {
|
final routerProvider = Provider<GoRouter>((ref) {
|
||||||
final authState = ref.watch(authControllerProvider);
|
final authState = ref.watch(authControllerProvider);
|
||||||
|
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
|
navigatorKey: rootNavigatorKey,
|
||||||
initialLocation: '/splash',
|
initialLocation: '/splash',
|
||||||
redirect: (BuildContext context, GoRouterState state) {
|
redirect: (BuildContext context, GoRouterState state) {
|
||||||
final isAuthenticated = authState.value?.isAuthenticated ?? false;
|
final isAuthenticated = authState.value?.isAuthenticated ?? false;
|
||||||
|
|||||||
@@ -1,38 +1,60 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Paleta institucional — Gobierno Municipal
|
||||||
|
/// ─────────────────────────────────────────
|
||||||
|
/// Vino principal #9B1B4A
|
||||||
|
/// Rosa institucional #C9A8B2
|
||||||
|
/// Beige dorado #C8A36A
|
||||||
|
/// Rosa claro fondo #E8D5DB
|
||||||
|
/// Verde acento #1E7A46
|
||||||
|
/// Azul accesibilidad #004A7C
|
||||||
|
/// Texto oscuro #1F1F1F
|
||||||
|
/// Fondo blanco #F8F8F8
|
||||||
class AppTheme {
|
class AppTheme {
|
||||||
static const Color primary = Color(0xFF1D9E75);
|
// ── Primarios ──────────────────────────────────────────────────────────────
|
||||||
static const Color primaryDark = Color(0xFF0F6E56);
|
static const Color primary = Color(0xFF9B1B4A);
|
||||||
static const Color primaryLight = Color(0xFFE1F5EE);
|
static const Color primaryDark = Color(0xFF6D1234);
|
||||||
static const Color primaryMid = Color(0xFF9FE1CB);
|
static const Color primaryLight = Color(0xFFE8D5DB);
|
||||||
|
static const Color primaryMid = Color(0xFFC9A8B2);
|
||||||
|
|
||||||
static const Color blue = Color(0xFF185FA5);
|
// ── Azul accesibilidad ─────────────────────────────────────────────────────
|
||||||
static const Color blueLight = Color(0xFFE6F1FB);
|
static const Color blue = Color(0xFF004A7C);
|
||||||
|
static const Color blueLight = Color(0xFFDCEAF3);
|
||||||
|
|
||||||
static const Color amber = Color(0xFF854F0B);
|
// ── Beige dorado (alertas / acento cálido) ─────────────────────────────────
|
||||||
static const Color amberLight = Color(0xFFFAEEDA);
|
static const Color amber = Color(0xFF8B6914);
|
||||||
|
static const Color amberLight = Color(0xFFF5EDD8);
|
||||||
|
|
||||||
static const Color danger = Color(0xFFE24B4A);
|
// ── Verde acento (éxito / estados activos) ─────────────────────────────────
|
||||||
|
static const Color accent = Color(0xFF1E7A46);
|
||||||
|
static const Color accentLight = Color(0xFFDFF0E8);
|
||||||
|
|
||||||
|
// ── Peligro ────────────────────────────────────────────────────────────────
|
||||||
|
static const Color danger = Color(0xFFD93040);
|
||||||
static const Color dangerLight = Color(0xFFFCEBEB);
|
static const Color dangerLight = Color(0xFFFCEBEB);
|
||||||
|
|
||||||
static const Color textPrimary = Color(0xFF1A1A1A);
|
// ── Texto ─────────────────────────────────────────────────────────────────
|
||||||
|
static const Color textPrimary = Color(0xFF1F1F1F);
|
||||||
static const Color textSecondary = Color(0xFF6B7280);
|
static const Color textSecondary = Color(0xFF6B7280);
|
||||||
static const Color textHint = Color(0xFFAAAAAA);
|
static const Color textHint = Color(0xFFAAAAAA);
|
||||||
|
|
||||||
|
// ── Superficies ────────────────────────────────────────────────────────────
|
||||||
static const Color surface = Color(0xFFFFFFFF);
|
static const Color surface = Color(0xFFFFFFFF);
|
||||||
static const Color background = Color(0xFFF5F7F5);
|
static const Color background = Color(0xFFF8F8F8);
|
||||||
static const Color border = Color(0xFFE5E7EB);
|
static const Color border = Color(0xFFE0D5D8);
|
||||||
static const Color borderLight = Color(0xFFF0F2F0);
|
static const Color borderLight = Color(0xFFF0ECF0);
|
||||||
|
|
||||||
|
// ── Radios ────────────────────────────────────────────────────────────────
|
||||||
static const double radiusSm = 8.0;
|
static const double radiusSm = 8.0;
|
||||||
static const double radiusMd = 12.0;
|
static const double radiusMd = 12.0;
|
||||||
static const double radiusLg = 16.0;
|
static const double radiusLg = 16.0;
|
||||||
static const double radiusXl = 24.0;
|
static const double radiusXl = 24.0;
|
||||||
static const double radiusFull = 100.0;
|
static const double radiusFull = 100.0;
|
||||||
|
|
||||||
|
// ── Sombras ───────────────────────────────────────────────────────────────
|
||||||
static List<BoxShadow> get cardShadow => [
|
static List<BoxShadow> get cardShadow => [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.06),
|
color: const Color(0xFF9B1B4A).withValues(alpha: 0.07),
|
||||||
blurRadius: 12,
|
blurRadius: 12,
|
||||||
offset: const Offset(0, 4),
|
offset: const Offset(0, 4),
|
||||||
),
|
),
|
||||||
@@ -46,12 +68,13 @@ class AppTheme {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ── Tema global ───────────────────────────────────────────────────────────
|
||||||
static ThemeData get lightTheme => ThemeData(
|
static ThemeData get lightTheme => ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
colorScheme: ColorScheme.fromSeed(
|
colorScheme: ColorScheme.fromSeed(
|
||||||
seedColor: primary,
|
seedColor: primary,
|
||||||
primary: primary,
|
primary: primary,
|
||||||
secondary: primaryDark,
|
secondary: accent,
|
||||||
surface: surface,
|
surface: surface,
|
||||||
),
|
),
|
||||||
scaffoldBackgroundColor: background,
|
scaffoldBackgroundColor: background,
|
||||||
@@ -62,9 +85,10 @@ class AppTheme {
|
|||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
titleTextStyle: TextStyle(
|
titleTextStyle: TextStyle(
|
||||||
inherit: false,
|
inherit: false,
|
||||||
fontSize: 18,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w700,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
|
letterSpacing: 0.1,
|
||||||
),
|
),
|
||||||
iconTheme: IconThemeData(color: Colors.white),
|
iconTheme: IconThemeData(color: Colors.white),
|
||||||
),
|
),
|
||||||
@@ -126,5 +150,15 @@ class AppTheme {
|
|||||||
labelStyle: const TextStyle(color: textSecondary, fontSize: 13),
|
labelStyle: const TextStyle(color: textSecondary, fontSize: 13),
|
||||||
hintStyle: const TextStyle(color: textHint, fontSize: 13),
|
hintStyle: const TextStyle(color: textHint, fontSize: 13),
|
||||||
),
|
),
|
||||||
|
switchTheme: SwitchThemeData(
|
||||||
|
thumbColor: WidgetStateProperty.resolveWith(
|
||||||
|
(s) => s.contains(WidgetState.selected) ? primary : Colors.white,
|
||||||
|
),
|
||||||
|
trackColor: WidgetStateProperty.resolveWith(
|
||||||
|
(s) => s.contains(WidgetState.selected)
|
||||||
|
? primary.withValues(alpha: 0.5)
|
||||||
|
: Colors.grey.shade300,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,15 +79,30 @@ class AppCard extends StatelessWidget {
|
|||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: padding ?? const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.surface,
|
color: AppTheme.surface,
|
||||||
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
||||||
border: Border.all(color: borderColor ?? AppTheme.border, width: 0.5),
|
border: Border.all(color: borderColor ?? AppTheme.border, width: 0.5),
|
||||||
boxShadow: AppTheme.softShadow,
|
boxShadow: AppTheme.softShadow,
|
||||||
),
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusLg - 0.5),
|
||||||
|
child: IntrinsicHeight(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Container(width: 3, color: AppTheme.primary),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: padding ?? const EdgeInsets.all(16),
|
||||||
child: child,
|
child: child,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,13 +125,22 @@ class AppInfoRow extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(14),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.surface,
|
color: AppTheme.surface,
|
||||||
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
||||||
border: Border.all(color: AppTheme.border, width: 0.5),
|
border: Border.all(color: AppTheme.border, width: 0.5),
|
||||||
boxShadow: AppTheme.softShadow,
|
boxShadow: AppTheme.softShadow,
|
||||||
),
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusLg - 0.5),
|
||||||
|
child: IntrinsicHeight(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Container(width: 3, color: AppTheme.primary),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
@@ -155,6 +179,12 @@ class AppInfoRow extends StatelessWidget {
|
|||||||
?trailing,
|
?trailing,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -305,8 +335,11 @@ class AppMenuTile extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GestureDetector(
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
|
||||||
@@ -318,7 +351,19 @@ class AppMenuTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, color: iconColor ?? AppTheme.primary, size: 20),
|
Container(
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: (iconColor ?? AppTheme.primary).withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
color: iconColor ?? AppTheme.primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -354,6 +399,7 @@ class AppMenuTile extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -375,39 +421,58 @@ class AppFormCard extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.surface,
|
color: AppTheme.surface,
|
||||||
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
||||||
border: Border.all(color: AppTheme.border, width: 0.5),
|
border: Border.all(color: AppTheme.border, width: 0.5),
|
||||||
boxShadow: AppTheme.softShadow,
|
boxShadow: AppTheme.softShadow,
|
||||||
),
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusLg - 0.5),
|
||||||
|
child: IntrinsicHeight(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Container(width: 3, color: AppTheme.primary),
|
||||||
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 12),
|
||||||
|
color: AppTheme.primaryLight,
|
||||||
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, color: AppTheme.primary, size: 18),
|
Icon(icon, color: AppTheme.primaryDark, size: 18),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppTheme.textPrimary,
|
color: AppTheme.primaryDark,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
),
|
||||||
child,
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Bottom Nav Bar ────────────────────────────────────────────────────────────
|
// ── Bottom Nav Bar (Material 3) ───────────────────────────────────────────────
|
||||||
class AppBottomNav extends StatelessWidget {
|
class AppBottomNav extends StatelessWidget {
|
||||||
final int currentIndex;
|
final int currentIndex;
|
||||||
final Function(int) onTap;
|
final Function(int) onTap;
|
||||||
@@ -420,35 +485,34 @@ class AppBottomNav extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BottomNavigationBar(
|
return NavigationBar(
|
||||||
currentIndex: currentIndex,
|
selectedIndex: currentIndex,
|
||||||
onTap: onTap,
|
onDestinationSelected: onTap,
|
||||||
type: BottomNavigationBarType.fixed,
|
|
||||||
backgroundColor: AppTheme.surface,
|
backgroundColor: AppTheme.surface,
|
||||||
selectedItemColor: AppTheme.primary,
|
indicatorColor: AppTheme.primaryLight,
|
||||||
unselectedItemColor: AppTheme.textSecondary,
|
surfaceTintColor: Colors.transparent,
|
||||||
selectedFontSize: 11,
|
shadowColor: AppTheme.primary.withValues(alpha: 0.08),
|
||||||
unselectedFontSize: 11,
|
elevation: 4,
|
||||||
elevation: 12,
|
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
|
||||||
items: const [
|
destinations: const [
|
||||||
BottomNavigationBarItem(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.notifications_outlined),
|
icon: Icon(Icons.notifications_outlined, color: AppTheme.textSecondary),
|
||||||
activeIcon: Icon(Icons.notifications),
|
selectedIcon: Icon(Icons.notifications, color: AppTheme.primary),
|
||||||
label: 'ETA',
|
label: 'ETA',
|
||||||
),
|
),
|
||||||
BottomNavigationBarItem(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.history_outlined),
|
icon: Icon(Icons.history_outlined, color: AppTheme.textSecondary),
|
||||||
activeIcon: Icon(Icons.history),
|
selectedIcon: Icon(Icons.history, color: AppTheme.primary),
|
||||||
label: 'Alertas',
|
label: 'Alertas',
|
||||||
),
|
),
|
||||||
BottomNavigationBarItem(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.home_outlined),
|
icon: Icon(Icons.home_outlined, color: AppTheme.textSecondary),
|
||||||
activeIcon: Icon(Icons.home),
|
selectedIcon: Icon(Icons.home, color: AppTheme.primary),
|
||||||
label: 'Mi casa',
|
label: 'Mi casa',
|
||||||
),
|
),
|
||||||
BottomNavigationBarItem(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.person_outline),
|
icon: Icon(Icons.person_outline, color: AppTheme.textSecondary),
|
||||||
activeIcon: Icon(Icons.person),
|
selectedIcon: Icon(Icons.person, color: AppTheme.primary),
|
||||||
label: 'Perfil',
|
label: 'Perfil',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
||||||
import '../../../core/theme/app_theme.dart';
|
import '../../../core/theme/app_theme.dart';
|
||||||
import '../../../core/widgets/app_widgets.dart';
|
|
||||||
|
|
||||||
class AboutScreen extends StatelessWidget {
|
class AboutScreen extends StatelessWidget {
|
||||||
const AboutScreen({super.key});
|
const AboutScreen({super.key});
|
||||||
@@ -11,93 +10,52 @@ class AboutScreen extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppTheme.background,
|
backgroundColor: AppTheme.background,
|
||||||
appBar: AppBar(title: const Text('Acerca de la app')),
|
|
||||||
body: FutureBuilder<PackageInfo>(
|
body: FutureBuilder<PackageInfo>(
|
||||||
future: PackageInfo.fromPlatform(),
|
future: PackageInfo.fromPlatform(),
|
||||||
builder: (context, snap) {
|
builder: (context, snap) {
|
||||||
final version = snap.data?.version ?? '1.0.0';
|
final version = snap.data?.version ?? '1.0.0';
|
||||||
final build = snap.data?.buildNumber ?? '1';
|
final build = snap.data?.buildNumber ?? '1';
|
||||||
return ListView(
|
return CustomScrollView(
|
||||||
padding: const EdgeInsets.all(16),
|
slivers: [
|
||||||
children: [
|
SliverToBoxAdapter(
|
||||||
AppCard(
|
child: _buildPageHeader(context, version, build),
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 72,
|
|
||||||
height: 72,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.primaryLight,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
),
|
||||||
child: const Icon(
|
SliverPadding(
|
||||||
Icons.recycling_rounded,
|
padding: const EdgeInsets.fromLTRB(20, 24, 20, 32),
|
||||||
size: 40,
|
sliver: SliverList(
|
||||||
color: AppTheme.primaryDark,
|
delegate: SliverChildListDelegate([
|
||||||
),
|
_SectionLabel('Descripción'),
|
||||||
),
|
const SizedBox(height: 10),
|
||||||
const SizedBox(height: 12),
|
_InfoCard(
|
||||||
const Text(
|
icon: Icons.info_outline_rounded,
|
||||||
'Recolecta',
|
content: 'RecolectApp es una aplicación del Servicio de Limpia de Celaya '
|
||||||
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 '
|
'para informar al ciudadano sobre rutas, horarios y separación '
|
||||||
'correcta de residuos.',
|
'correcta de residuos.',
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
height: 1.5,
|
|
||||||
color: AppTheme.textPrimary,
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_SectionLabel('Créditos'),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_InfoCard(
|
||||||
|
icon: Icons.people_outline_rounded,
|
||||||
|
content: 'Desarrollado por el equipo ONLINCESHACK.\nServicio de Limpia · Celaya, Gto.',
|
||||||
),
|
),
|
||||||
],
|
const SizedBox(height: 20),
|
||||||
),
|
_SectionLabel('Tecnología'),
|
||||||
),
|
const SizedBox(height: 10),
|
||||||
const SizedBox(height: 16),
|
_TechRow(icon: Icons.phone_android_rounded, label: 'Flutter · Dart'),
|
||||||
AppCard(
|
const SizedBox(height: 8),
|
||||||
child: Column(
|
_TechRow(icon: Icons.cloud_outlined, label: 'FastAPI · Supabase'),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
const SizedBox(height: 8),
|
||||||
children: const [
|
_TechRow(icon: Icons.notifications_outlined, label: 'Firebase Cloud Messaging'),
|
||||||
AppSectionTitle(title: 'Créditos'),
|
const SizedBox(height: 32),
|
||||||
Text(
|
Center(
|
||||||
'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(
|
child: Text(
|
||||||
'© 2025 Recolecta',
|
'© 2025 RecolectApp · Todos los derechos reservados',
|
||||||
style: TextStyle(fontSize: 12, color: AppTheme.textHint),
|
style: const TextStyle(fontSize: 11, color: AppTheme.textHint),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -106,4 +64,208 @@ class AboutScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildPageHeader(BuildContext context, String version, String build) {
|
||||||
|
return Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
stops: [0.0, 0.6, 1.0],
|
||||||
|
colors: [Color(0xFF4A0E26), Color(0xFF6D1234), Color(0xFF9B1B4A)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
bottom: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 32),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back_ios_new_rounded,
|
||||||
|
color: Colors.white, size: 20),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Center(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.15),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white.withValues(alpha: 0.3),
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.recycling_rounded,
|
||||||
|
size: 42,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
const Text(
|
||||||
|
'RecolectApp',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: Colors.white,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Versión $version (build $build)',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Colors.white.withValues(alpha: 0.75),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(100),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white.withValues(alpha: 0.25)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Servicio de Limpia · Celaya, Gto.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Colors.white.withValues(alpha: 0.9),
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SectionLabel extends StatelessWidget {
|
||||||
|
final String text;
|
||||||
|
const _SectionLabel(this.text);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Text(
|
||||||
|
text.toUpperCase(),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InfoCard extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String content;
|
||||||
|
const _InfoCard({required this.icon, required this.content});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.outlineVariant,
|
||||||
|
width: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(9.5),
|
||||||
|
child: IntrinsicHeight(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Container(width: 3, color: AppTheme.primary),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryLight,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: 18, color: AppTheme.primary),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
content,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.5,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TechRow extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
const _TechRow({required this.icon, required this.label});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.outlineVariant, width: 0.5),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: AppTheme.primary),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(fontSize: 13, color: AppTheme.textPrimary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,24 +213,13 @@ class _AddAddressPageState extends ConsumerState<AddAddressPage> {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppTheme.background,
|
backgroundColor: AppTheme.background,
|
||||||
appBar: AppBar(
|
body: CustomScrollView(
|
||||||
backgroundColor: Colors.transparent,
|
slivers: [
|
||||||
elevation: 0,
|
SliverToBoxAdapter(child: _buildPageHeader(context)),
|
||||||
iconTheme: const IconThemeData(color: AppTheme.textPrimary),
|
SliverPadding(
|
||||||
title: const Text(
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
|
||||||
'Agregar dirección',
|
sliver: SliverList(
|
||||||
style: TextStyle(
|
delegate: SliverChildListDelegate([
|
||||||
color: AppTheme.textPrimary,
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
AppFormCard(
|
AppFormCard(
|
||||||
icon: Icons.home_outlined,
|
icon: Icons.home_outlined,
|
||||||
title: 'Dirección de tu casa',
|
title: 'Dirección de tu casa',
|
||||||
@@ -255,7 +244,8 @@ class _AddAddressPageState extends ConsumerState<AddAddressPage> {
|
|||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.primaryLight.withValues(alpha: 0.5),
|
color: AppTheme.primaryLight.withValues(alpha: 0.5),
|
||||||
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
|
borderRadius:
|
||||||
|
BorderRadius.circular(AppTheme.radiusSm),
|
||||||
border: Border.all(color: AppTheme.primaryMid),
|
border: Border.all(color: AppTheme.primaryMid),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -280,7 +270,8 @@ class _AddAddressPageState extends ConsumerState<AddAddressPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (_selectedColonia!.horarioEstimado != null) ...[
|
if (_selectedColonia!.horarioEstimado !=
|
||||||
|
null) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Horario ${_selectedColonia!.turno?.toLowerCase() ?? ''}',
|
'Horario ${_selectedColonia!.turno?.toLowerCase() ?? ''}',
|
||||||
@@ -318,10 +309,12 @@ class _AddAddressPageState extends ConsumerState<AddAddressPage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Container(
|
Container(
|
||||||
height: 200,
|
height: 220,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
|
borderRadius:
|
||||||
|
BorderRadius.circular(AppTheme.radiusMd),
|
||||||
border: Border.all(color: AppTheme.border),
|
border: Border.all(color: AppTheme.border),
|
||||||
|
boxShadow: AppTheme.softShadow,
|
||||||
),
|
),
|
||||||
clipBehavior: Clip.hardEdge,
|
clipBehavior: Clip.hardEdge,
|
||||||
child: FlutterMap(
|
child: FlutterMap(
|
||||||
@@ -330,7 +323,8 @@ class _AddAddressPageState extends ConsumerState<AddAddressPage> {
|
|||||||
initialCenter: mapCenter,
|
initialCenter: mapCenter,
|
||||||
initialZoom: 15.0,
|
initialZoom: 15.0,
|
||||||
cameraConstraint: bounds != null
|
cameraConstraint: bounds != null
|
||||||
? CameraConstraint.containCenter(bounds: bounds)
|
? CameraConstraint.containCenter(
|
||||||
|
bounds: bounds)
|
||||||
: const CameraConstraint.unconstrained(),
|
: const CameraConstraint.unconstrained(),
|
||||||
onTap: (_, latlng) => _fetchStreetName(latlng),
|
onTap: (_, latlng) => _fetchStreetName(latlng),
|
||||||
),
|
),
|
||||||
@@ -338,7 +332,8 @@ class _AddAddressPageState extends ConsumerState<AddAddressPage> {
|
|||||||
TileLayer(
|
TileLayer(
|
||||||
urlTemplate:
|
urlTemplate:
|
||||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
userAgentPackageName: 'com.onlineshack.recolecta',
|
userAgentPackageName:
|
||||||
|
'com.onlineshack.recolecta',
|
||||||
),
|
),
|
||||||
if (_selectedLocation != null)
|
if (_selectedLocation != null)
|
||||||
MarkerLayer(
|
MarkerLayer(
|
||||||
@@ -374,10 +369,98 @@ class _AddAddressPageState extends ConsumerState<AddAddressPage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 32),
|
||||||
SizedBox(
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
bottomNavigationBar: _buildSaveButton(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPageHeader(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
20,
|
||||||
|
MediaQuery.of(context).padding.top + 12,
|
||||||
|
20,
|
||||||
|
24,
|
||||||
|
),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [Color(0xFF4A0E26), Color(0xFF9B1B4A)],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
bottomLeft: Radius.circular(28),
|
||||||
|
bottomRight: Radius.circular(28),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => Navigator.of(context).pop(),
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.arrow_back,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
const Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Agregar dirección',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'Registra tu domicilio de recolección',
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.white70),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.add_home_outlined,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSaveButton() {
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
|
||||||
|
child: SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 52,
|
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: _loading ? null : _guardar,
|
onPressed: _loading ? null : _guardar,
|
||||||
child: AnimatedSwitcher(
|
child: AnimatedSwitcher(
|
||||||
@@ -392,11 +475,9 @@ class _AddAddressPageState extends ConsumerState<AddAddressPage> {
|
|||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: const FittedBox(
|
: const Row(
|
||||||
key: ValueKey('text'),
|
key: ValueKey('text'),
|
||||||
fit: BoxFit.scaleDown,
|
mainAxisSize: MainAxisSize.min,
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.check, size: 18),
|
Icon(Icons.check, size: 18),
|
||||||
SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
@@ -407,10 +488,6 @@ class _AddAddressPageState extends ConsumerState<AddAddressPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class AddressMapCard extends StatelessWidget {
|
|||||||
// Si existen coordenadas exactas las usa, de lo contrario cae al centro de la colonia
|
// Si existen coordenadas exactas las usa, de lo contrario cae al centro de la colonia
|
||||||
final center = (lat != null && lng != null)
|
final center = (lat != null && lng != null)
|
||||||
? LatLng(lat!, lng!)
|
? LatLng(lat!, lng!)
|
||||||
: kColoniaCenter(colonia);
|
: kColoniasCoordinates[colonia] ?? const LatLng(20.5222, -100.8123);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 16),
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../../core/theme/app_theme.dart';
|
|||||||
import '../../core/widgets/app_widgets.dart';
|
import '../../core/widgets/app_widgets.dart';
|
||||||
import 'data/admin_service.dart';
|
import 'data/admin_service.dart';
|
||||||
import 'models/admin_driver.dart';
|
import 'models/admin_driver.dart';
|
||||||
|
import 'models/admin_incident.dart';
|
||||||
import 'models/admin_route.dart';
|
import 'models/admin_route.dart';
|
||||||
import 'models/admin_unit.dart';
|
import 'models/admin_unit.dart';
|
||||||
import 'models/admin_user.dart';
|
import 'models/admin_user.dart';
|
||||||
@@ -67,6 +68,23 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handleSimulationTick() async {
|
||||||
|
try {
|
||||||
|
final events = await _service.simulationTick();
|
||||||
|
if (events.isEmpty) {
|
||||||
|
_snack('Tick enviado · sin eventos nuevos en esta posición');
|
||||||
|
} else {
|
||||||
|
final tipos = events
|
||||||
|
.map((e) => e['event']?.toString() ?? '?')
|
||||||
|
.toSet()
|
||||||
|
.join(', ');
|
||||||
|
_snack('Tick enviado · ${events.length} push FCM ($tipos)');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_snack('No se pudo avanzar la simulación: $e', error: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -74,6 +92,11 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Panel de administración'),
|
title: const Text('Panel de administración'),
|
||||||
actions: [
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Avanzar simulación (envía push FCM)',
|
||||||
|
icon: const Icon(Icons.play_circle_fill_rounded),
|
||||||
|
onPressed: _handleSimulationTick,
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Refrescar',
|
tooltip: 'Refrescar',
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
@@ -101,7 +124,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
|
|||||||
tabs: const [
|
tabs: const [
|
||||||
Tab(text: 'Usuarios'),
|
Tab(text: 'Usuarios'),
|
||||||
Tab(text: 'Rutas'),
|
Tab(text: 'Rutas'),
|
||||||
Tab(text: 'Camiones'),
|
Tab(text: 'Unidades'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -118,7 +141,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
|
|||||||
? 'Nuevo usuario'
|
? 'Nuevo usuario'
|
||||||
: _activeTab == 1
|
: _activeTab == 1
|
||||||
? 'Nueva ruta'
|
? 'Nueva ruta'
|
||||||
: 'Nuevo camión',
|
: 'Nueva unidad',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -372,7 +395,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
|
|||||||
DropdownButtonFormField<int?>(
|
DropdownButtonFormField<int?>(
|
||||||
initialValue: truckId,
|
initialValue: truckId,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Camión asignado',
|
labelText: 'Unidad asignada',
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(
|
borderRadius: BorderRadius.circular(
|
||||||
AppTheme.radiusMd,
|
AppTheme.radiusMd,
|
||||||
@@ -445,7 +468,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Formulario camión (unit) ────────────────────────────────────────────────
|
// ── Formulario unidad ───────────────────────────────────────────────────────
|
||||||
Future<void> _showUnitForm({AdminUnitModel? unit}) async {
|
Future<void> _showUnitForm({AdminUnitModel? unit}) async {
|
||||||
final isEdit = unit != null;
|
final isEdit = unit != null;
|
||||||
final idCtrl = TextEditingController(text: unit?.id.toString() ?? '');
|
final idCtrl = TextEditingController(text: unit?.id.toString() ?? '');
|
||||||
@@ -463,7 +486,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
|
|||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
||||||
),
|
),
|
||||||
title: Text(isEdit ? 'Editar camión' : 'Nuevo camión'),
|
title: Text(isEdit ? 'Editar unidad' : 'Nueva unidad'),
|
||||||
content: Form(
|
content: Form(
|
||||||
key: formKey,
|
key: formKey,
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
@@ -561,7 +584,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
|
|||||||
if (saved == true) {
|
if (saved == true) {
|
||||||
ref.invalidate(adminUnitsProvider);
|
ref.invalidate(adminUnitsProvider);
|
||||||
ref.invalidate(adminRoutesProvider);
|
ref.invalidate(adminRoutesProvider);
|
||||||
_snack(isEdit ? 'Camión actualizado' : 'Camión creado');
|
_snack(isEdit ? 'Unidad actualizada' : 'Unidad creada');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -774,7 +797,7 @@ class _RoutesTab extends ConsumerWidget {
|
|||||||
style: const TextStyle(fontSize: 13),
|
style: const TextStyle(fontSize: 13),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'Camión: ${unit?.displayPlate ?? (r.truckId == null ? 'Sin asignar' : '#${r.truckId}')}',
|
'Unidad: ${unit?.displayPlate ?? (r.truckId == null ? 'Sin asignar' : '#${r.truckId}')}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: AppTheme.textSecondary,
|
color: AppTheme.textSecondary,
|
||||||
@@ -845,6 +868,7 @@ class _RoutesTab extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Tab Unidades ──────────────────────────────────────────────────────────────
|
||||||
class _TrucksTab extends ConsumerWidget {
|
class _TrucksTab extends ConsumerWidget {
|
||||||
const _TrucksTab();
|
const _TrucksTab();
|
||||||
|
|
||||||
@@ -866,7 +890,7 @@ class _TrucksTab extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
data: (units) {
|
data: (units) {
|
||||||
if (units.isEmpty) {
|
if (units.isEmpty) {
|
||||||
return const _EmptyView('No hay camiones registrados.');
|
return const _EmptyView('No hay unidades registradas.');
|
||||||
}
|
}
|
||||||
return ListView.separated(
|
return ListView.separated(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
|
||||||
@@ -892,6 +916,7 @@ class _TrucksTab extends ConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
// ── Encabezado: placa + badge estado ─────────────────
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -907,6 +932,7 @@ class _TrucksTab extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
|
// ── Detalles ──────────────────────────────────────────
|
||||||
Text(
|
Text(
|
||||||
'ID: #${t.id}',
|
'ID: #${t.id}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
@@ -926,9 +952,21 @@ class _TrucksTab extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
// ── Botones de acción ─────────────────────────────────
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
|
// Ver incidencias
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () => _showIncidentsSheet(context, ref, t),
|
||||||
|
icon: const Icon(Icons.warning_amber_rounded, size: 18),
|
||||||
|
label: const Text('Incidencias'),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Colors.orange.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
// Editar
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final state = context
|
final state = context
|
||||||
@@ -938,11 +976,12 @@ class _TrucksTab extends ConsumerWidget {
|
|||||||
icon: const Icon(Icons.edit_outlined, size: 18),
|
icon: const Icon(Icons.edit_outlined, size: 18),
|
||||||
label: const Text('Editar'),
|
label: const Text('Editar'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 4),
|
||||||
|
// Eliminar
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: () => _confirmAndDelete(
|
onPressed: () => _confirmAndDelete(
|
||||||
context,
|
context,
|
||||||
tipo: 'camión',
|
tipo: 'unidad',
|
||||||
onConfirm: () async {
|
onConfirm: () async {
|
||||||
await ref
|
await ref
|
||||||
.read(adminServiceProvider)
|
.read(adminServiceProvider)
|
||||||
@@ -968,6 +1007,23 @@ class _TrucksTab extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Abre el bottom sheet de incidencias ───────────────────────────────────
|
||||||
|
void _showIncidentsSheet(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
AdminUnitModel unit,
|
||||||
|
) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: AppTheme.surface,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (_) => _IncidentsSheet(unit: unit),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _unitStatusBadge(String status) {
|
Widget _unitStatusBadge(String status) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'inactive':
|
case 'inactive':
|
||||||
@@ -980,6 +1036,374 @@ class _TrucksTab extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Bottom sheet de incidencias ───────────────────────────────────────────────
|
||||||
|
class _IncidentsSheet extends ConsumerStatefulWidget {
|
||||||
|
const _IncidentsSheet({required this.unit});
|
||||||
|
final AdminUnitModel unit;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_IncidentsSheet> createState() => _IncidentsSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _IncidentsSheetState extends ConsumerState<_IncidentsSheet> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final async = ref.watch(adminIncidentsByUnitProvider(widget.unit.id));
|
||||||
|
|
||||||
|
return DraggableScrollableSheet(
|
||||||
|
expand: false,
|
||||||
|
initialChildSize: 0.6,
|
||||||
|
maxChildSize: 0.92,
|
||||||
|
builder: (_, controller) => Column(
|
||||||
|
children: [
|
||||||
|
// ── Handle ─────────────────────────────────────────────────
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// ── Header ─────────────────────────────────────────────────
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.warning_amber_rounded,
|
||||||
|
color: Colors.orange,
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Incidencias — ${widget.unit.displayPlate}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.add_circle_outline,
|
||||||
|
color: AppTheme.primary,
|
||||||
|
),
|
||||||
|
tooltip: 'Nueva incidencia',
|
||||||
|
onPressed: () => _showCreateIncidentDialog(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
// ── Lista ───────────────────────────────────────────────────
|
||||||
|
Expanded(
|
||||||
|
child: async.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, _) => Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
color: AppTheme.danger,
|
||||||
|
size: 40,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
e.toString(),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => ref.invalidate(
|
||||||
|
adminIncidentsByUnitProvider(widget.unit.id),
|
||||||
|
),
|
||||||
|
child: const Text('Reintentar'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
data: (incidents) {
|
||||||
|
if (incidents.isEmpty) {
|
||||||
|
return const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(24),
|
||||||
|
child: Text(
|
||||||
|
'Sin incidencias registradas.',
|
||||||
|
style: TextStyle(color: AppTheme.textSecondary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ListView.separated(
|
||||||
|
controller: controller,
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 32),
|
||||||
|
itemCount: incidents.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||||
|
itemBuilder: (_, i) => _IncidentCard(incident: incidents[i]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showCreateIncidentDialog(BuildContext context) async {
|
||||||
|
String type = 'otro';
|
||||||
|
final desc = TextEditingController();
|
||||||
|
final formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
final saved = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => StatefulBuilder(
|
||||||
|
builder: (ctx, setStateDialog) => AlertDialog(
|
||||||
|
backgroundColor: AppTheme.surface,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
||||||
|
),
|
||||||
|
title: const Text('Nueva incidencia'),
|
||||||
|
content: Form(
|
||||||
|
key: formKey,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
initialValue: type,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Categoría',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'derrame',
|
||||||
|
child: Text('💧 Derrame'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'dano_propiedad',
|
||||||
|
child: Text('💥 Daño a propiedad'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'conducta',
|
||||||
|
child: Text('😠 Conducta'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'no_recoleccion',
|
||||||
|
child: Text('🗑 No recolección'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(value: 'otro', child: Text('📋 Otro')),
|
||||||
|
],
|
||||||
|
onChanged: (v) {
|
||||||
|
if (v != null) setStateDialog(() => type = v);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
TextFormField(
|
||||||
|
controller: desc,
|
||||||
|
maxLines: 3,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Descripción',
|
||||||
|
helperText: 'Mínimo 3 caracteres',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
validator: (v) {
|
||||||
|
final t = (v ?? '').trim();
|
||||||
|
if (t.length < 3) return 'Describe brevemente lo ocurrido';
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('Cancelar'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
if (!(formKey.currentState?.validate() ?? false)) return;
|
||||||
|
try {
|
||||||
|
await ref
|
||||||
|
.read(adminServiceProvider)
|
||||||
|
.createIncident(
|
||||||
|
unitId: widget.unit.id,
|
||||||
|
type: type,
|
||||||
|
description: desc.text.trim(),
|
||||||
|
);
|
||||||
|
if (ctx.mounted) Navigator.pop(ctx, true);
|
||||||
|
} catch (e) {
|
||||||
|
if (ctx.mounted) {
|
||||||
|
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Error: $e'),
|
||||||
|
backgroundColor: AppTheme.danger,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Guardar'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (saved == true) {
|
||||||
|
ref.invalidate(adminIncidentsByUnitProvider(widget.unit.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tarjeta individual de incidencia ──────────────────────────────────────────
|
||||||
|
class _IncidentCard extends StatelessWidget {
|
||||||
|
const _IncidentCard({required this.incident});
|
||||||
|
final AdminIncidentModel incident;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AppCard(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_typeIcon(incident.type),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// ── Tipo + fecha ──────────────────────────────────────
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_typeBadge(incident.type),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
_formatDate(incident.createdAt),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// ── Conductor ─────────────────────────────────────────
|
||||||
|
if (incident.driverName != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.person_outline,
|
||||||
|
size: 14,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
incident.driverName!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
// ── Ruta ─────────────────────────────────────────────
|
||||||
|
if (incident.routeId != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'Ruta: ${incident.routeId}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
// ── Descripción ───────────────────────────────────────
|
||||||
|
if (incident.description != null &&
|
||||||
|
incident.description!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
incident.description!,
|
||||||
|
style: const TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _typeIcon(String type) {
|
||||||
|
IconData icon;
|
||||||
|
Color color;
|
||||||
|
switch (type) {
|
||||||
|
case 'derrame':
|
||||||
|
icon = Icons.water_drop_outlined;
|
||||||
|
color = Colors.blue;
|
||||||
|
break;
|
||||||
|
case 'dano_propiedad':
|
||||||
|
icon = Icons.report_gmailerrorred_outlined;
|
||||||
|
color = AppTheme.danger;
|
||||||
|
break;
|
||||||
|
case 'conducta':
|
||||||
|
icon = Icons.sentiment_very_dissatisfied_outlined;
|
||||||
|
color = Colors.orange;
|
||||||
|
break;
|
||||||
|
case 'no_recoleccion':
|
||||||
|
icon = Icons.delete_forever_outlined;
|
||||||
|
color = Colors.deepOrange;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
icon = Icons.info_outline;
|
||||||
|
color = AppTheme.textSecondary;
|
||||||
|
}
|
||||||
|
return Icon(icon, color: color, size: 22);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _typeBadge(String type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'derrame':
|
||||||
|
return AppStatusBadge.amber('Derrame');
|
||||||
|
case 'dano_propiedad':
|
||||||
|
return AppStatusBadge.danger('Daño');
|
||||||
|
case 'conducta':
|
||||||
|
return AppStatusBadge.amber('Conducta');
|
||||||
|
case 'no_recoleccion':
|
||||||
|
return AppStatusBadge.danger('No recolección');
|
||||||
|
default:
|
||||||
|
return AppStatusBadge.gray('Otro');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDate(DateTime dt) {
|
||||||
|
final d = dt.toLocal();
|
||||||
|
final day = d.day.toString().padLeft(2, '0');
|
||||||
|
final month = d.month.toString().padLeft(2, '0');
|
||||||
|
final hour = d.hour.toString().padLeft(2, '0');
|
||||||
|
final minute = d.minute.toString().padLeft(2, '0');
|
||||||
|
return '$day/$month/${d.year} $hour:$minute';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Shared widgets ────────────────────────────────────────────────────────────
|
// ── Shared widgets ────────────────────────────────────────────────────────────
|
||||||
class _EmptyView extends StatelessWidget {
|
class _EmptyView extends StatelessWidget {
|
||||||
const _EmptyView(this.message);
|
const _EmptyView(this.message);
|
||||||
@@ -1079,4 +1503,3 @@ void _confirmAndDelete(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// EOF
|
// EOF
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../../../core/network/api_client.dart';
|
import '../../../core/network/api_client.dart';
|
||||||
import '../models/admin_driver.dart';
|
import '../models/admin_driver.dart';
|
||||||
|
import '../models/admin_incident.dart';
|
||||||
import '../models/admin_route.dart';
|
import '../models/admin_route.dart';
|
||||||
import '../models/admin_unit.dart';
|
import '../models/admin_unit.dart';
|
||||||
import '../models/admin_user.dart';
|
import '../models/admin_user.dart';
|
||||||
@@ -188,4 +189,42 @@ class AdminService {
|
|||||||
Future<void> deleteDriver(String id) async {
|
Future<void> deleteDriver(String id) async {
|
||||||
await _dio.delete<void>('/admin/drivers/$id');
|
await _dio.delete<void>('/admin/drivers/$id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Incidents ────────────────────────────────────────────────────────────────
|
||||||
|
Future<List<AdminIncidentModel>> listIncidentsByUnit(int unitId) async {
|
||||||
|
final res = await _dio.get<List<dynamic>>('/admin/units/$unitId/incidents');
|
||||||
|
return (res.data ?? [])
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => AdminIncidentModel.fromJson(Map<String, dynamic>.from(e)))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AdminIncidentModel> createIncident({
|
||||||
|
required int unitId,
|
||||||
|
required String type,
|
||||||
|
String? description,
|
||||||
|
}) async {
|
||||||
|
final res = await _dio.post<Map<String, dynamic>>(
|
||||||
|
'/admin/units/$unitId/incidents',
|
||||||
|
data: {
|
||||||
|
'unit_id': unitId,
|
||||||
|
'type': type,
|
||||||
|
if (description != null && description.isNotEmpty)
|
||||||
|
'description': description,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return AdminIncidentModel.fromJson(res.data!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Simulación ──────────────────────────────────────────────────────────────
|
||||||
|
/// Avanza una vez la simulación (`positionId += 1` en todas las rutas) y
|
||||||
|
/// dispara los push FCM correspondientes. Devuelve los eventos disparados.
|
||||||
|
Future<List<Map<String, dynamic>>> simulationTick() async {
|
||||||
|
final res = await _dio.post<Map<String, dynamic>>('/simulation/tick');
|
||||||
|
final events = (res.data?['events'] as List?) ?? const [];
|
||||||
|
return events
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => Map<String, dynamic>.from(e))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
67
recolecta_app/lib/features/admin/models/admin_incident.dart
Normal file
67
recolecta_app/lib/features/admin/models/admin_incident.dart
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// lib/features/admin/models/admin_incident.dart
|
||||||
|
|
||||||
|
class AdminIncidentModel {
|
||||||
|
final String id;
|
||||||
|
final int unitId;
|
||||||
|
final String? routeId;
|
||||||
|
// Mapea a `incidents.category` en la base de datos.
|
||||||
|
final String type;
|
||||||
|
final String? description;
|
||||||
|
final String? driverName;
|
||||||
|
final String status; // open | in_review | resolved
|
||||||
|
final String? photoUrl;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
const AdminIncidentModel({
|
||||||
|
required this.id,
|
||||||
|
required this.unitId,
|
||||||
|
this.routeId,
|
||||||
|
required this.type,
|
||||||
|
this.description,
|
||||||
|
this.driverName,
|
||||||
|
this.status = 'open',
|
||||||
|
this.photoUrl,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AdminIncidentModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
AdminIncidentModel(
|
||||||
|
// El id en DB es BIGSERIAL; el backend lo serializa como string,
|
||||||
|
// pero por defensa aceptamos number también.
|
||||||
|
id: json['id'].toString(),
|
||||||
|
unitId: (json['unit_id'] as num).toInt(),
|
||||||
|
routeId: json['route_id'] as String?,
|
||||||
|
type: (json['type'] as String?) ?? 'otro',
|
||||||
|
description: json['description'] as String?,
|
||||||
|
driverName: json['driver_name'] as String?,
|
||||||
|
status: (json['status'] as String?) ?? 'open',
|
||||||
|
photoUrl: json['photo_url'] as String?,
|
||||||
|
createdAt: DateTime.parse(json['created_at'] as String),
|
||||||
|
);
|
||||||
|
|
||||||
|
String get typeLabel {
|
||||||
|
switch (type) {
|
||||||
|
case 'derrame':
|
||||||
|
return 'Derrame';
|
||||||
|
case 'dano_propiedad':
|
||||||
|
return 'Daño a propiedad';
|
||||||
|
case 'conducta':
|
||||||
|
return 'Conducta';
|
||||||
|
case 'no_recoleccion':
|
||||||
|
return 'No recolección';
|
||||||
|
default:
|
||||||
|
return 'Otro';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String get statusLabel {
|
||||||
|
switch (status) {
|
||||||
|
case 'in_review':
|
||||||
|
return 'En revisión';
|
||||||
|
case 'resolved':
|
||||||
|
return 'Resuelta';
|
||||||
|
default:
|
||||||
|
return 'Abierta';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../data/admin_service.dart';
|
import '../data/admin_service.dart';
|
||||||
import '../models/admin_driver.dart';
|
import '../models/admin_driver.dart';
|
||||||
|
import '../models/admin_incident.dart';
|
||||||
import '../models/admin_route.dart';
|
import '../models/admin_route.dart';
|
||||||
import '../models/admin_unit.dart';
|
import '../models/admin_unit.dart';
|
||||||
import '../models/admin_user.dart';
|
import '../models/admin_user.dart';
|
||||||
@@ -21,3 +22,8 @@ final adminUnitsProvider = FutureProvider<List<AdminUnitModel>>((ref) {
|
|||||||
final adminDriversProvider = FutureProvider<List<AdminDriverModel>>((ref) {
|
final adminDriversProvider = FutureProvider<List<AdminDriverModel>>((ref) {
|
||||||
return ref.read(adminServiceProvider).listDrivers();
|
return ref.read(adminServiceProvider).listDrivers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final adminIncidentsByUnitProvider =
|
||||||
|
FutureProvider.family<List<AdminIncidentModel>, int>((ref, unitId) {
|
||||||
|
return ref.read(adminServiceProvider).listIncidentsByUnit(unitId);
|
||||||
|
});
|
||||||
|
|||||||
38
recolecta_app/lib/features/alerts/alerts_provider.dart
Normal file
38
recolecta_app/lib/features/alerts/alerts_provider.dart
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
class AppAlert {
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final String body;
|
||||||
|
final DateTime timestamp;
|
||||||
|
|
||||||
|
AppAlert({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.body,
|
||||||
|
required this.timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AlertsNotifier extends Notifier<List<AppAlert>> {
|
||||||
|
@override
|
||||||
|
List<AppAlert> build() => [];
|
||||||
|
|
||||||
|
void addAlert(String title, String body) {
|
||||||
|
state = [
|
||||||
|
AppAlert(
|
||||||
|
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
),
|
||||||
|
...state,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearAll() => state = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final alertsProvider = NotifierProvider<AlertsNotifier, List<AppAlert>>(
|
||||||
|
AlertsNotifier.new,
|
||||||
|
);
|
||||||
@@ -1,218 +1,65 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../core/models/ui_models.dart';
|
|
||||||
import '../../core/widgets/app_widgets.dart';
|
|
||||||
|
|
||||||
class AlertsScreen extends StatefulWidget {
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import 'alerts_provider.dart';
|
||||||
|
|
||||||
|
class AlertsScreen extends ConsumerWidget {
|
||||||
const AlertsScreen({super.key});
|
const AlertsScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AlertsScreen> createState() => _AlertsScreenState();
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
}
|
final alerts = ref.watch(alertsProvider);
|
||||||
|
|
||||||
class _AlertsScreenState extends State<AlertsScreen> {
|
|
||||||
final UIAlertaModel _alertaActiva = UIAlertaModel(
|
|
||||||
id: 'alerta-001',
|
|
||||||
tipo: TipoAlerta.cercana,
|
|
||||||
distanciaMetros: 180,
|
|
||||||
fecha: DateTime.now(),
|
|
||||||
direccionCasa: 'Av. Insurgentes 245',
|
|
||||||
leida: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
final List<UIAlertaModel> _historial = [
|
|
||||||
UIAlertaModel(
|
|
||||||
id: 'h-001',
|
|
||||||
tipo: TipoAlerta.cercana,
|
|
||||||
distanciaMetros: 200,
|
|
||||||
fecha: DateTime.now().subtract(const Duration(hours: 1)),
|
|
||||||
direccionCasa: 'Av. Insurgentes 245',
|
|
||||||
leida: true,
|
|
||||||
),
|
|
||||||
UIAlertaModel(
|
|
||||||
id: 'h-002',
|
|
||||||
tipo: TipoAlerta.cercana,
|
|
||||||
distanciaMetros: 200,
|
|
||||||
fecha: DateTime.now().subtract(const Duration(days: 2, hours: 2)),
|
|
||||||
direccionCasa: 'Av. Insurgentes 245',
|
|
||||||
leida: true,
|
|
||||||
),
|
|
||||||
UIAlertaModel(
|
|
||||||
id: 'h-003',
|
|
||||||
tipo: TipoAlerta.cercana,
|
|
||||||
distanciaMetros: 200,
|
|
||||||
fecha: DateTime.now().subtract(
|
|
||||||
const Duration(days: 4, hours: 1, minutes: 30)),
|
|
||||||
direccionCasa: 'Av. Insurgentes 245',
|
|
||||||
leida: true,
|
|
||||||
),
|
|
||||||
UIAlertaModel(
|
|
||||||
id: 'h-004',
|
|
||||||
tipo: TipoAlerta.cercana,
|
|
||||||
distanciaMetros: 200,
|
|
||||||
fecha: DateTime.now().subtract(const Duration(days: 7, hours: 3)),
|
|
||||||
direccionCasa: 'Av. Insurgentes 245',
|
|
||||||
leida: true,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppTheme.background,
|
backgroundColor: AppTheme.background,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Alertas'),
|
title: const Text('Mis Alertas'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
if (alerts.isNotEmpty)
|
||||||
onPressed: () {},
|
IconButton(
|
||||||
child: const Text('Limpiar',
|
icon: const Icon(Icons.delete_sweep_outlined),
|
||||||
style: TextStyle(color: Colors.white, fontSize: 13)),
|
tooltip: 'Limpiar historial',
|
||||||
|
onPressed: () => ref.read(alertsProvider.notifier).clearAll(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: RefreshIndicator(
|
body: alerts.isEmpty
|
||||||
color: AppTheme.primary,
|
? _buildEmptyState()
|
||||||
onRefresh: () async =>
|
: ListView.separated(
|
||||||
Future.delayed(const Duration(milliseconds: 800)),
|
|
||||||
child: ListView(
|
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
itemCount: alerts.length,
|
||||||
_AlertaActivaCard(alerta: _alertaActiva),
|
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||||
const SizedBox(height: 20),
|
itemBuilder: (context, index) {
|
||||||
if (_historial.isEmpty)
|
return _AlertCard(alert: alerts[index]);
|
||||||
const _EmptyState()
|
},
|
||||||
else ...[
|
|
||||||
const AppSectionTitle(title: 'Historial de alertas'),
|
|
||||||
..._historial.map((a) => _AlertaHistorialItem(alerta: a)),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// ── Alerta activa ─────────────────────────────────────────────────────────────
|
Widget _buildEmptyState() {
|
||||||
class _AlertaActivaCard extends StatelessWidget {
|
return const Center(
|
||||||
final UIAlertaModel alerta;
|
|
||||||
const _AlertaActivaCard({required this.alerta});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final progreso = (1 - (alerta.distanciaMetros / 400)).clamp(0.0, 1.0);
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(18),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.primaryLight,
|
|
||||||
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
|
||||||
border: Border.all(color: AppTheme.primaryMid),
|
|
||||||
boxShadow: AppTheme.softShadow,
|
|
||||||
),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Icon(
|
||||||
children: [
|
Icons.notifications_off_outlined,
|
||||||
Container(
|
size: 64,
|
||||||
width: 40,
|
color: AppTheme.textSecondary,
|
||||||
height: 40,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.primary,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.notifications_active,
|
SizedBox(height: 16),
|
||||||
color: Colors.white, size: 22),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text('¡El camión está cerca!',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: AppTheme.primaryDark)),
|
|
||||||
Text(alerta.fechaFormateada,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12, color: AppTheme.primary)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
padding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.primary,
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
|
||||||
child: const Text('Ahora',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Colors.white)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Text('El camión se encuentra a',
|
|
||||||
style: TextStyle(fontSize: 13, color: AppTheme.primaryDark)),
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
Text(
|
Text(
|
||||||
alerta.distanciaTexto,
|
'No tienes notificaciones nuevas',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 36,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppTheme.primary,
|
color: AppTheme.textPrimary,
|
||||||
height: 1.1),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 6),
|
|
||||||
child: Text('de tu casa en ${alerta.direccionCasa}',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 13, color: AppTheme.primaryDark)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Text('Llegada estimada:',
|
|
||||||
style:
|
|
||||||
TextStyle(fontSize: 12, color: AppTheme.primaryDark)),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(alerta.tiempoEstimadoTexto,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: AppTheme.primary)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
child: LinearProgressIndicator(
|
|
||||||
value: progreso,
|
|
||||||
backgroundColor:
|
|
||||||
AppTheme.primaryMid.withValues(alpha: 0.4),
|
|
||||||
valueColor:
|
|
||||||
const AlwaysStoppedAnimation<Color>(AppTheme.primary),
|
|
||||||
minHeight: 7,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
SizedBox(height: 8),
|
||||||
const Row(
|
Text(
|
||||||
children: [
|
'Aquí aparecerán los avisos del camión.',
|
||||||
Text('Lejos',
|
style: TextStyle(fontSize: 14, color: AppTheme.textSecondary),
|
||||||
style: TextStyle(fontSize: 10, color: AppTheme.primary)),
|
|
||||||
Spacer(),
|
|
||||||
Text('Tu casa',
|
|
||||||
style: TextStyle(fontSize: 10, color: AppTheme.primary)),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -220,107 +67,76 @@ class _AlertaActivaCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Ítem de historial ─────────────────────────────────────────────────────────
|
class _AlertCard extends StatelessWidget {
|
||||||
class _AlertaHistorialItem extends StatelessWidget {
|
final AppAlert alert;
|
||||||
final UIAlertaModel alerta;
|
|
||||||
const _AlertaHistorialItem({required this.alerta});
|
const _AlertCard({required this.alert});
|
||||||
|
|
||||||
|
String _timeAgo(DateTime date) {
|
||||||
|
final diff = DateTime.now().difference(date);
|
||||||
|
if (diff.inMinutes < 1) return 'Justo ahora';
|
||||||
|
if (diff.inHours < 1) return 'Hace ${diff.inMinutes} min';
|
||||||
|
if (diff.inDays < 1) return 'Hace ${diff.inHours} h';
|
||||||
|
return 'Hace ${diff.inDays} d';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
padding: const EdgeInsets.all(16),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.surface,
|
color: AppTheme.surface,
|
||||||
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
||||||
border: Border.all(color: AppTheme.border, width: 0.5),
|
border: Border.all(color: AppTheme.border, width: 0.5),
|
||||||
boxShadow: AppTheme.softShadow,
|
boxShadow: AppTheme.softShadow,
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 36,
|
padding: const EdgeInsets.all(10),
|
||||||
height: 36,
|
decoration: const BoxDecoration(
|
||||||
decoration: BoxDecoration(
|
color: AppTheme.primaryLight,
|
||||||
color: AppTheme.background,
|
shape: BoxShape.circle,
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.notifications_outlined,
|
child: const Icon(
|
||||||
color: AppTheme.textSecondary, size: 18),
|
Icons.notifications_active_outlined,
|
||||||
|
color: AppTheme.primary,
|
||||||
|
size: 24,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('Camión a ${alerta.distanciaTexto}',
|
Text(
|
||||||
|
alert.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppTheme.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
alert.body,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
color: AppTheme.textPrimary,
|
||||||
color: AppTheme.textPrimary)),
|
height: 1.4,
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(alerta.fechaFormateada,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12, color: AppTheme.textSecondary)),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_EtiquetaDia(texto: alerta.etiquetaFecha),
|
const SizedBox(height: 8),
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _EtiquetaDia extends StatelessWidget {
|
|
||||||
final String texto;
|
|
||||||
const _EtiquetaDia({required this.texto});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final esHoy = texto == 'Hoy';
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: esHoy ? AppTheme.primaryLight : AppTheme.background,
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
texto,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: esHoy ? AppTheme.primaryDark : AppTheme.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Sin alertas ───────────────────────────────────────────────────────────────
|
|
||||||
class _EmptyState extends StatelessWidget {
|
|
||||||
const _EmptyState();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return const Padding(
|
|
||||||
padding: EdgeInsets.symmetric(vertical: 60),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.notifications_outlined,
|
|
||||||
color: AppTheme.primary, size: 48),
|
|
||||||
SizedBox(height: 16),
|
|
||||||
Text('Sin alertas por ahora',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppTheme.textPrimary)),
|
|
||||||
SizedBox(height: 6),
|
|
||||||
Text(
|
Text(
|
||||||
'Te notificaremos cuando el camión\nesté cerca de tu casa.',
|
_timeAgo(alert.timestamp),
|
||||||
textAlign: TextAlign.center,
|
style: const TextStyle(
|
||||||
style: TextStyle(
|
fontSize: 12,
|
||||||
fontSize: 13, color: AppTheme.textSecondary, height: 1.5),
|
color: AppTheme.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
key: ValueKey('loading'),
|
key: ValueKey('loading'),
|
||||||
fit: BoxFit.scaleDown,
|
fit: BoxFit.scaleDown,
|
||||||
child: Row(
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
VideoMascot(size: 34, zoom: 1.5),
|
VideoMascot(size: 34, zoom: 1.5),
|
||||||
@@ -227,7 +228,7 @@ class _GreenHeader extends StatelessWidget {
|
|||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
stops: [0.0, 0.6, 1.0],
|
stops: [0.0, 0.6, 1.0],
|
||||||
colors: [Color(0xFF0A4A38), Color(0xFF0F6E56), Color(0xFF1D9E75)],
|
colors: [Color(0xFF4A0E26), Color(0xFF6D1234), Color(0xFF9B1B4A)],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
|
|||||||
@@ -112,7 +112,6 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
setState(() => _currentPage = 1);
|
setState(() => _currentPage = 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Llama a la API de OpenStreetMap (Nominatim) para obtener la calle automáticamente
|
|
||||||
Future<void> _fetchStreetName(LatLng latlng) async {
|
Future<void> _fetchStreetName(LatLng latlng) async {
|
||||||
setState(() => _selectedLocation = latlng);
|
setState(() => _selectedLocation = latlng);
|
||||||
|
|
||||||
@@ -200,7 +199,7 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
_selectedColonia = backendC;
|
_selectedColonia = backendC;
|
||||||
_selectedLocation = kColoniasCoordinates[nombre];
|
_selectedLocation = kColoniasCoordinates[nombre];
|
||||||
});
|
});
|
||||||
FocusScope.of(context).unfocus(); // Cierra el teclado
|
FocusScope.of(context).unfocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onRegister() {
|
void _onRegister() {
|
||||||
@@ -226,20 +225,11 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppTheme.background,
|
backgroundColor: AppTheme.background,
|
||||||
appBar: AppBar(
|
body: Column(
|
||||||
backgroundColor: Colors.transparent,
|
children: [
|
||||||
elevation: 0,
|
_buildPageHeader(context),
|
||||||
iconTheme: const IconThemeData(color: AppTheme.textPrimary),
|
Expanded(
|
||||||
title: Text(
|
child: PageView(
|
||||||
_currentPage == 0 ? 'Crear cuenta' : 'Mi dirección',
|
|
||||||
style: const TextStyle(color: AppTheme.textPrimary, fontSize: 16),
|
|
||||||
),
|
|
||||||
bottom: PreferredSize(
|
|
||||||
preferredSize: const Size.fromHeight(8),
|
|
||||||
child: _StepIndicator(current: _currentPage, total: 2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: PageView(
|
|
||||||
controller: _pageController,
|
controller: _pageController,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
children: [
|
children: [
|
||||||
@@ -247,15 +237,76 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
_buildStep2(context, loading, coloniasList),
|
_buildStep2(context, loading, coloniasList),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
bottomNavigationBar: _buildBottomControls(context, loading),
|
bottomNavigationBar: _buildBottomControls(context, loading),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildPageHeader(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
20,
|
||||||
|
MediaQuery.of(context).padding.top + 12,
|
||||||
|
20,
|
||||||
|
20,
|
||||||
|
),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [Color(0xFF4A0E26), Color(0xFF9B1B4A)],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
bottomLeft: Radius.circular(28),
|
||||||
|
bottomRight: Radius.circular(28),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => Navigator.of(context).pop(),
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.arrow_back,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Text(
|
||||||
|
_currentPage == 0 ? 'Crear cuenta' : 'Mi dirección',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_HorizontalStepIndicator(current: _currentPage, total: 2),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildStep1(BuildContext context) {
|
Widget _buildStep1(BuildContext context) {
|
||||||
return Form(
|
return Form(
|
||||||
key: _step1FormKey,
|
key: _step1FormKey,
|
||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 24, 20, 40),
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 40),
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
const Text(
|
||||||
'Crea tu cuenta',
|
'Crea tu cuenta',
|
||||||
@@ -265,29 +316,36 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
color: AppTheme.textPrimary,
|
color: AppTheme.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 6),
|
||||||
const Text(
|
const Text(
|
||||||
'Ingresa tus datos para registrarte.',
|
'Ingresa tus datos para registrarte.',
|
||||||
style: TextStyle(fontSize: 15, color: AppTheme.textSecondary),
|
style: TextStyle(fontSize: 15, color: AppTheme.textSecondary),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 24),
|
||||||
|
AppFormCard(
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
title: 'Información de cuenta',
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
AppFormField(
|
AppFormField(
|
||||||
controller: _nameCtrl,
|
controller: _nameCtrl,
|
||||||
label: 'Nombre completo',
|
label: 'Nombre completo',
|
||||||
validator: (val) =>
|
validator: (val) =>
|
||||||
val!.isEmpty ? 'Ingresa tu nombre completo' : null,
|
val!.isEmpty ? 'Ingresa tu nombre completo' : null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 14),
|
||||||
AppFormField(
|
AppFormField(
|
||||||
controller: _emailCtrl,
|
controller: _emailCtrl,
|
||||||
label: 'Correo electrónico',
|
label: 'Correo electrónico',
|
||||||
hint: 'tu@correo.com',
|
hint: 'tu@correo.com',
|
||||||
keyboardType: TextInputType.emailAddress,
|
keyboardType: TextInputType.emailAddress,
|
||||||
validator: (v) {
|
validator: (v) {
|
||||||
if (v == null || v.trim().isEmpty) return 'Ingresa tu correo';
|
if (v == null || v.trim().isEmpty)
|
||||||
|
return 'Ingresa tu correo';
|
||||||
final emailRegex = RegExp(r'^[^@]+@[^@]+\.[^@]+');
|
final emailRegex = RegExp(r'^[^@]+@[^@]+\.[^@]+');
|
||||||
if (!emailRegex.hasMatch(v.trim()))
|
if (!emailRegex.hasMatch(v.trim())) {
|
||||||
return 'Ingresa un correo válido';
|
return 'Ingresa un correo válido';
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -312,7 +370,34 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
size: 18,
|
size: 18,
|
||||||
color: AppTheme.textSecondary,
|
color: AppTheme.textSecondary,
|
||||||
),
|
),
|
||||||
onPressed: () => setState(() => _obscurePass = !_obscurePass),
|
onPressed: () =>
|
||||||
|
setState(() => _obscurePass = !_obscurePass),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Center(
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'¿Ya tienes cuenta? ',
|
||||||
|
style: TextStyle(fontSize: 13, color: AppTheme.textSecondary),
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => context.go('/login'),
|
||||||
|
child: const Text(
|
||||||
|
'Inicia sesión',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -326,11 +411,24 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
List<Colonia> coloniasList,
|
List<Colonia> coloniasList,
|
||||||
) {
|
) {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 40),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 4),
|
const Text(
|
||||||
|
'Tu dirección',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
const Text(
|
||||||
|
'Ubica tu domicilio para recibir alertas.',
|
||||||
|
style: TextStyle(fontSize: 15, color: AppTheme.textSecondary),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
AppFormCard(
|
AppFormCard(
|
||||||
icon: Icons.home_outlined,
|
icon: Icons.home_outlined,
|
||||||
title: 'Dirección de tu casa',
|
title: 'Dirección de tu casa',
|
||||||
@@ -453,10 +551,11 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Container(
|
Container(
|
||||||
height: 200,
|
height: 220,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
|
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||||
border: Border.all(color: AppTheme.border),
|
border: Border.all(color: AppTheme.border),
|
||||||
|
boxShadow: AppTheme.softShadow,
|
||||||
),
|
),
|
||||||
clipBehavior: Clip.hardEdge,
|
clipBehavior: Clip.hardEdge,
|
||||||
child: FlutterMap(
|
child: FlutterMap(
|
||||||
@@ -510,7 +609,6 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// ── Sección OCR (Privacidad por diseño) ──
|
|
||||||
AppFormCard(
|
AppFormCard(
|
||||||
icon: Icons.document_scanner_outlined,
|
icon: Icons.document_scanner_outlined,
|
||||||
title: 'Verificación de Domicilio',
|
title: 'Verificación de Domicilio',
|
||||||
@@ -538,6 +636,9 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
'Escanear recibo (OCR)',
|
'Escanear recibo (OCR)',
|
||||||
style: TextStyle(color: AppTheme.primary),
|
style: TextStyle(color: AppTheme.primary),
|
||||||
),
|
),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
side: const BorderSide(color: AppTheme.primary),
|
||||||
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
@@ -552,64 +653,20 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// ── Sección WhatsApp ──
|
|
||||||
AppFormCard(
|
AppFormCard(
|
||||||
icon: Icons.chat_outlined,
|
icon: Icons.chat_outlined,
|
||||||
title: 'Notificaciones Externas',
|
title: 'Notificaciones Externas',
|
||||||
child: Column(
|
child: Material(
|
||||||
children: [
|
|
||||||
Material(
|
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: CheckboxListTile(
|
child: CheckboxListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
controlAffinity: ListTileControlAffinity.leading,
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
activeColor: AppTheme.primary,
|
activeColor: AppTheme.primary,
|
||||||
value: _whatsappNotif,
|
value: _whatsappNotif,
|
||||||
onChanged: (v) =>
|
onChanged: (v) => setState(() => _whatsappNotif = v ?? false),
|
||||||
setState(() => _whatsappNotif = v ?? false),
|
|
||||||
title: const Text(
|
title: const Text(
|
||||||
'Recibir alertas del camión vía WhatsApp (Próximamente)',
|
'Recibir alertas del camión vía WhatsApp',
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 14, color: AppTheme.textPrimary),
|
||||||
fontSize: 14,
|
|
||||||
color: AppTheme.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 28),
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
height: 52,
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: loading ? null : _onRegister,
|
|
||||||
child: AnimatedSwitcher(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
child: loading
|
|
||||||
? const SizedBox(
|
|
||||||
key: ValueKey('loading'),
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const Row(
|
|
||||||
key: ValueKey('text'),
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.check, size: 18),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
'Registrarme',
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -633,9 +690,12 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
|
|
||||||
Widget _buildBottomControls(BuildContext context, bool isLoading) {
|
Widget _buildBottomControls(BuildContext context, bool isLoading) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(
|
padding: EdgeInsets.fromLTRB(
|
||||||
20,
|
24,
|
||||||
).copyWith(bottom: MediaQuery.of(context).padding.bottom + 20),
|
12,
|
||||||
|
24,
|
||||||
|
MediaQuery.of(context).padding.bottom + 16,
|
||||||
|
),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: AppTheme.background,
|
color: AppTheme.background,
|
||||||
border: Border(top: BorderSide(color: AppTheme.border, width: 0.5)),
|
border: Border(top: BorderSide(color: AppTheme.border, width: 0.5)),
|
||||||
@@ -670,511 +730,107 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Indicador de pasos ────────────────────────────────────────────────────────
|
// ── Indicador horizontal de pasos ─────────────────────────────────────────────
|
||||||
class _StepIndicator extends StatelessWidget {
|
class _HorizontalStepIndicator extends StatelessWidget {
|
||||||
final int current;
|
final int current;
|
||||||
final int total;
|
final int total;
|
||||||
const _StepIndicator({required this.current, required this.total});
|
const _HorizontalStepIndicator({required this.current, required this.total});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return Row(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6),
|
|
||||||
child: Row(
|
|
||||||
children: List.generate(total, (i) {
|
children: List.generate(total, (i) {
|
||||||
final active = i <= current;
|
final isCompleted = i < current;
|
||||||
|
final isActive = i == current;
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: Container(
|
child: Row(
|
||||||
margin: EdgeInsets.only(right: i < total - 1 ? 6 : 0),
|
children: [
|
||||||
height: 4,
|
_StepCircle(
|
||||||
decoration: BoxDecoration(
|
index: i + 1,
|
||||||
color: active ? AppTheme.primary : AppTheme.border,
|
isCompleted: isCompleted,
|
||||||
borderRadius: BorderRadius.circular(4),
|
isActive: isActive,
|
||||||
),
|
),
|
||||||
|
if (i < total - 1)
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
height: 2,
|
||||||
|
color: isCompleted
|
||||||
|
? Colors.white.withValues(alpha: 0.8)
|
||||||
|
: Colors.white.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Paso 1: Cuenta ────────────────────────────────────────────────────────────
|
class _StepCircle extends StatelessWidget {
|
||||||
class _Step1 extends StatelessWidget {
|
final int index;
|
||||||
final GlobalKey<FormState> formKey;
|
final bool isCompleted;
|
||||||
final TextEditingController emailCtrl, telefonoCtrl, passCtrl;
|
final bool isActive;
|
||||||
final bool obscurePass;
|
const _StepCircle({
|
||||||
final VoidCallback onTogglePass;
|
required this.index,
|
||||||
final VoidCallback onNext;
|
required this.isCompleted,
|
||||||
|
required this.isActive,
|
||||||
const _Step1({
|
|
||||||
required this.formKey,
|
|
||||||
required this.emailCtrl,
|
|
||||||
required this.telefonoCtrl,
|
|
||||||
required this.passCtrl,
|
|
||||||
required this.obscurePass,
|
|
||||||
required this.onTogglePass,
|
|
||||||
required this.onNext,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SingleChildScrollView(
|
return Column(
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Form(
|
|
||||||
key: formKey,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 4),
|
AnimatedContainer(
|
||||||
AppFormCard(
|
duration: const Duration(milliseconds: 250),
|
||||||
icon: Icons.person_outline,
|
width: 32,
|
||||||
title: 'Información de cuenta',
|
height: 32,
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
AppFormField(
|
|
||||||
label: 'Correo electrónico',
|
|
||||||
hint: 'tu@correo.com',
|
|
||||||
controller: emailCtrl,
|
|
||||||
keyboardType: TextInputType.emailAddress,
|
|
||||||
validator: (v) {
|
|
||||||
if (v == null || v.trim().isEmpty)
|
|
||||||
return 'Ingresa tu correo';
|
|
||||||
final emailRegex = RegExp(r'^[^@]+@[^@]+\.[^@]+');
|
|
||||||
if (!emailRegex.hasMatch(v.trim()))
|
|
||||||
return 'Ingresa un correo válido';
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
_PhoneField(controller: telefonoCtrl),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
AppFormField(
|
|
||||||
label: 'Contraseña',
|
|
||||||
hint: '••••••••',
|
|
||||||
controller: passCtrl,
|
|
||||||
obscureText: obscurePass,
|
|
||||||
validator: (v) {
|
|
||||||
if (v == null || v.isEmpty)
|
|
||||||
return 'Ingresa una contraseña';
|
|
||||||
if (v.length < 6) return 'Mínimo 6 caracteres';
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
suffix: IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
obscurePass
|
|
||||||
? Icons.visibility_outlined
|
|
||||||
: Icons.visibility_off_outlined,
|
|
||||||
size: 18,
|
|
||||||
color: AppTheme.textSecondary,
|
|
||||||
),
|
|
||||||
onPressed: onTogglePass,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 28),
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
height: 52,
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: onNext,
|
|
||||||
child: const Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text('Siguiente'),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
Icon(Icons.arrow_forward, size: 18),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
Center(
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'¿Ya tienes cuenta? ',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
color: AppTheme.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => context.go('/login'),
|
|
||||||
child: const Text(
|
|
||||||
'Inicia sesión',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppTheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Paso 2: Dirección ─────────────────────────────────────────────────────────
|
|
||||||
class _Step2 extends StatelessWidget {
|
|
||||||
final MapController mapController;
|
|
||||||
final TextEditingController cpCtrl;
|
|
||||||
final TextEditingController calleCtrl;
|
|
||||||
final Colonia? selectedColonia;
|
|
||||||
final LatLng? selectedLocation;
|
|
||||||
final String tipoInmueble;
|
|
||||||
final bool whatsappNotif;
|
|
||||||
final bool loading;
|
|
||||||
final ValueChanged<String> onTipoChanged;
|
|
||||||
final ValueChanged<String> onCPChanged;
|
|
||||||
final ValueChanged<LatLng> onLocationChanged;
|
|
||||||
final ValueChanged<bool?> onWhatsappChanged;
|
|
||||||
final VoidCallback onRegister;
|
|
||||||
|
|
||||||
const _Step2({
|
|
||||||
required this.mapController,
|
|
||||||
required this.cpCtrl,
|
|
||||||
required this.calleCtrl,
|
|
||||||
required this.selectedColonia,
|
|
||||||
required this.selectedLocation,
|
|
||||||
required this.tipoInmueble,
|
|
||||||
required this.whatsappNotif,
|
|
||||||
required this.loading,
|
|
||||||
required this.onTipoChanged,
|
|
||||||
required this.onCPChanged,
|
|
||||||
required this.onLocationChanged,
|
|
||||||
required this.onWhatsappChanged,
|
|
||||||
required this.onRegister,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
// Usamos el centro original de la colonia para los límites estáticos
|
|
||||||
final baseCenter = selectedColonia != null
|
|
||||||
? kColoniasCoordinates[selectedColonia!.nombre] ??
|
|
||||||
const LatLng(20.5222, -100.8123)
|
|
||||||
: const LatLng(20.5222, -100.8123);
|
|
||||||
|
|
||||||
final mapCenter = selectedLocation ?? baseCenter;
|
|
||||||
|
|
||||||
return SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
AppFormCard(
|
|
||||||
icon: Icons.home_outlined,
|
|
||||||
title: 'Dirección de tu casa',
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Tipo de inmueble',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: AppTheme.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
child: RadioListTile<String>(
|
|
||||||
title: const Text(
|
|
||||||
'Casa',
|
|
||||||
style: TextStyle(fontSize: 14),
|
|
||||||
),
|
|
||||||
value: 'Casa',
|
|
||||||
groupValue: tipoInmueble,
|
|
||||||
onChanged: (v) => onTipoChanged(v!),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
child: RadioListTile<String>(
|
|
||||||
title: const Text(
|
|
||||||
'Negocio',
|
|
||||||
style: TextStyle(fontSize: 14),
|
|
||||||
),
|
|
||||||
value: 'Negocio',
|
|
||||||
groupValue: tipoInmueble,
|
|
||||||
onChanged: (v) => onTipoChanged(v!),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
AppFormField(
|
|
||||||
label: 'Código Postal',
|
|
||||||
hint: 'Ej. 38000',
|
|
||||||
controller: cpCtrl,
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
onChanged: onCPChanged,
|
|
||||||
),
|
|
||||||
|
|
||||||
if (selectedColonia != null) ...[
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(14),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.primaryLight.withValues(alpha: 0.5),
|
shape: BoxShape.circle,
|
||||||
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
|
color: (isCompleted || isActive)
|
||||||
border: Border.all(color: AppTheme.primaryMid),
|
? Colors.white
|
||||||
|
: Colors.white.withValues(alpha: 0.2),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white.withValues(alpha: 0.6),
|
||||||
|
width: 1.5,
|
||||||
),
|
),
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Icon(
|
|
||||||
Icons.check_circle_outline,
|
|
||||||
color: AppTheme.primary,
|
|
||||||
size: 18,
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
child: Center(
|
||||||
Expanded(
|
child: isCompleted
|
||||||
child: Text(
|
? const Icon(Icons.check, size: 16, color: AppTheme.primary)
|
||||||
'Colonia: ${selectedColonia!.nombre}',
|
: Text(
|
||||||
style: const TextStyle(
|
'$index',
|
||||||
fontWeight: FontWeight.w600,
|
style: TextStyle(
|
||||||
color: AppTheme.primaryDark,
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: isActive
|
||||||
|
? AppTheme.primary
|
||||||
|
: Colors.white.withValues(alpha: 0.6),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Horario ${selectedColonia!.turno?.toLowerCase() ?? 'asignado'}',
|
index == 1 ? 'Cuenta' : 'Dirección',
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
color: AppTheme.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
selectedColonia!.horarioEstimado ??
|
|
||||||
'Sin horario especificado',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: AppTheme.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
AppFormField(
|
|
||||||
label: 'Calle y número',
|
|
||||||
hint: 'Av. Insurgentes 245',
|
|
||||||
controller: calleCtrl,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Text(
|
|
||||||
'Toca el mapa para ubicar tu casa exacta:',
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 10,
|
||||||
fontWeight: FontWeight.w500,
|
color: isActive || isCompleted
|
||||||
color: AppTheme.textSecondary,
|
? Colors.white
|
||||||
),
|
: Colors.white.withValues(alpha: 0.5),
|
||||||
),
|
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
||||||
const SizedBox(height: 8),
|
|
||||||
Container(
|
|
||||||
height: 200,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
|
|
||||||
border: Border.all(color: AppTheme.border),
|
|
||||||
),
|
|
||||||
clipBehavior: Clip.hardEdge,
|
|
||||||
child: FlutterMap(
|
|
||||||
mapController: mapController,
|
|
||||||
options: MapOptions(
|
|
||||||
initialCenter: mapCenter,
|
|
||||||
initialZoom: 15.0,
|
|
||||||
onTap: (_, latlng) => onLocationChanged(latlng),
|
|
||||||
),
|
|
||||||
children: [
|
|
||||||
TileLayer(
|
|
||||||
urlTemplate:
|
|
||||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
|
||||||
userAgentPackageName: 'com.onlineshack.recolecta',
|
|
||||||
),
|
|
||||||
if (selectedLocation != null)
|
|
||||||
MarkerLayer(
|
|
||||||
markers: [
|
|
||||||
Marker(
|
|
||||||
point: selectedLocation!,
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
child: const Icon(
|
|
||||||
Icons.location_on,
|
|
||||||
color: AppTheme.danger,
|
|
||||||
size: 40,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
] else ...[
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
const Center(
|
|
||||||
child: Text(
|
|
||||||
'Ingresa un código postal con servicio\npara asignar tu colonia.',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppTheme.textSecondary,
|
|
||||||
fontSize: 13,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// ── Sección OCR (Privacidad por diseño) ──
|
|
||||||
AppFormCard(
|
|
||||||
icon: Icons.document_scanner_outlined,
|
|
||||||
title: 'Verificación de Domicilio',
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Para prevenir abusos, requerimos validar tu dirección con un recibo (luz o agua). '
|
|
||||||
'Por privacidad, la imagen será borrada inmediatamente después de la lectura.',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
color: AppTheme.textSecondary,
|
|
||||||
height: 1.4,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: OutlinedButton.icon(
|
|
||||||
icon: const Icon(
|
|
||||||
Icons.upload_file,
|
|
||||||
color: AppTheme.primary,
|
|
||||||
),
|
|
||||||
label: const Text(
|
|
||||||
'Escanear recibo (OCR)',
|
|
||||||
style: TextStyle(color: AppTheme.primary),
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Abriendo cámara... (Próximamente)'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// ── Sección WhatsApp ──
|
|
||||||
AppFormCard(
|
|
||||||
icon: Icons.chat_outlined,
|
|
||||||
title: 'Notificaciones Externas',
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
child: CheckboxListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
controlAffinity: ListTileControlAffinity.leading,
|
|
||||||
activeColor: AppTheme.primary,
|
|
||||||
value: whatsappNotif,
|
|
||||||
onChanged: onWhatsappChanged,
|
|
||||||
title: const Text(
|
|
||||||
'Recibir alertas del camión vía WhatsApp (Próximamente)',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
color: AppTheme.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 28),
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
height: 52,
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: loading ? null : onRegister,
|
|
||||||
child: AnimatedSwitcher(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
child: loading
|
|
||||||
? const SizedBox(
|
|
||||||
key: ValueKey('loading'),
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const FittedBox(
|
|
||||||
key: ValueKey('text'),
|
|
||||||
fit: BoxFit.scaleDown,
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.check, size: 18),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
Text('Registrarme'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Center(
|
|
||||||
child: Text(
|
|
||||||
'Al registrarte aceptas los Términos de Servicio\ny la Política de Privacidad.',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
color: AppTheme.textSecondary,
|
|
||||||
height: 1.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Campo de teléfono con lada ────────────────────────────────────────────────
|
// ── Campo de teléfono con lada ────────────────────────────────────────────────
|
||||||
// Muestra +52 🇲🇽 fijo (escalable a selector multi-país en el futuro).
|
|
||||||
// Formatea la entrada como 000-000-0000 y valida exactamente 10 dígitos.
|
|
||||||
class _PhoneField extends StatelessWidget {
|
class _PhoneField extends StatelessWidget {
|
||||||
final TextEditingController controller;
|
final TextEditingController controller;
|
||||||
const _PhoneField({required this.controller});
|
const _PhoneField({required this.controller});
|
||||||
|
|
||||||
// Países disponibles (lista para escalamiento futuro)
|
|
||||||
static const _ladas = [(flag: '🇲🇽', code: '+52', name: 'México')];
|
static const _ladas = [(flag: '🇲🇽', code: '+52', name: 'México')];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -1195,7 +851,6 @@ class _PhoneField extends StatelessWidget {
|
|||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Selector de lada (por ahora solo +52)
|
|
||||||
Container(
|
Container(
|
||||||
height: 50,
|
height: 50,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
@@ -1221,7 +876,6 @@ class _PhoneField extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
// Número (solo dígitos, formato 000-000-0000)
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
@@ -1275,7 +929,7 @@ class _PhoneField extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
validator: (v) {
|
validator: (v) {
|
||||||
if (v == null || v.isEmpty) return null; // opcional
|
if (v == null || v.isEmpty) return null;
|
||||||
final digits = v.replaceAll('-', '');
|
final digits = v.replaceAll('-', '');
|
||||||
if (digits.length != 10)
|
if (digits.length != 10)
|
||||||
return 'Ingresa exactamente 10 dígitos';
|
return 'Ingresa exactamente 10 dígitos';
|
||||||
@@ -1290,7 +944,6 @@ class _PhoneField extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Formatea dígitos en tiempo real: 4611234567 → 461-123-4567
|
|
||||||
class _PhoneInputFormatter extends TextInputFormatter {
|
class _PhoneInputFormatter extends TextInputFormatter {
|
||||||
@override
|
@override
|
||||||
TextEditingValue formatEditUpdate(
|
TextEditingValue formatEditUpdate(
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ class VideoMascot extends StatelessWidget {
|
|||||||
'assets/animations/blink_saludo.gif',
|
'assets/animations/blink_saludo.gif',
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (context, error, stackTrace) {
|
errorBuilder: (context, error, stackTrace) {
|
||||||
// Plan B: si el archivo no existe o hay error, mostramos la huellita
|
// Plan B: si el archivo no existe o hay error, mostramos el bote
|
||||||
return const Center(
|
return const Center(
|
||||||
child: Icon(Icons.pets, color: Colors.white, size: 48),
|
child: Icon(Icons.delete_outline, color: Colors.white, size: 48),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -123,6 +123,12 @@ class _EtaNotifier extends AsyncNotifier<_EtaResult> {
|
|||||||
if (items.isEmpty) return const _EtaResult.noAddress();
|
if (items.isEmpty) return const _EtaResult.noAddress();
|
||||||
|
|
||||||
final addressId = items.first['id'] as String;
|
final addressId = items.first['id'] as String;
|
||||||
|
final routeId = items.first['route_id'] as String?;
|
||||||
|
// Publica el routeId activo para que `_FcmStatusBadge` deje de mostrar
|
||||||
|
// "suscribiendo..." y refleje el topic real al que está suscrito el cliente.
|
||||||
|
Future.microtask(() {
|
||||||
|
ref.read(activeRouteIdProvider.notifier).set(routeId);
|
||||||
|
});
|
||||||
final etaResp = await dio.get<dynamic>(
|
final etaResp = await dio.get<dynamic>(
|
||||||
'/eta',
|
'/eta',
|
||||||
queryParameters: {'address_id': addressId},
|
queryParameters: {'address_id': addressId},
|
||||||
@@ -149,6 +155,10 @@ final etaProvider = AsyncNotifierProvider<_EtaNotifier, _EtaResult>(
|
|||||||
class ActiveRouteIdNotifier extends Notifier<String?> {
|
class ActiveRouteIdNotifier extends Notifier<String?> {
|
||||||
@override
|
@override
|
||||||
String? build() => null;
|
String? build() => null;
|
||||||
|
|
||||||
|
void set(String? value) {
|
||||||
|
if (state != value) state = value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final activeRouteIdProvider = NotifierProvider<ActiveRouteIdNotifier, String?>(
|
final activeRouteIdProvider = NotifierProvider<ActiveRouteIdNotifier, String?>(
|
||||||
@@ -312,7 +322,11 @@ class _EcoChatBanner extends StatelessWidget {
|
|||||||
color: Colors.white24,
|
color: Colors.white24,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.pets, color: Colors.white, size: 28),
|
child: const Icon(
|
||||||
|
Icons.delete_outline,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
const Expanded(
|
const Expanded(
|
||||||
@@ -408,14 +422,14 @@ class _EtaHeroCard extends StatelessWidget {
|
|||||||
Color _bgColor(BuildContext context) {
|
Color _bgColor(BuildContext context) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
if (result.isCompleted) return cs.surfaceContainerHighest;
|
if (result.isCompleted) return cs.surfaceContainerHighest;
|
||||||
if (result.isNearby) return const Color(0xFFFFF8E1); // amber-50
|
if (result.isNearby) return const Color(0xFFF5EDD8); // beige dorado claro
|
||||||
return const Color(0xFFE1F5EE); // teal-50
|
return const Color(0xFFE8D5DB); // rosa claro institucional
|
||||||
}
|
}
|
||||||
|
|
||||||
Color _accentColor(BuildContext context) {
|
Color _accentColor(BuildContext context) {
|
||||||
if (result.isCompleted) return Theme.of(context).colorScheme.outline;
|
if (result.isCompleted) return Theme.of(context).colorScheme.outline;
|
||||||
if (result.isNearby) return const Color(0xFFBA7517); // amber-400
|
if (result.isNearby) return const Color(0xFFC8A36A); // beige dorado
|
||||||
return const Color(0xFF1D9E75); // teal-400
|
return const Color(0xFF9B1B4A); // vino principal
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -693,7 +707,7 @@ class _FcmStatusBadge extends ConsumerWidget {
|
|||||||
width: 8,
|
width: 8,
|
||||||
height: 8,
|
height: 8,
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: Color(0xFF1D9E75),
|
color: Color(0xFF1E7A46),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// lib/features/feedback/feedback_provider.dart
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../core/network/api_client.dart';
|
||||||
|
import 'feedback_model.dart';
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────
|
||||||
|
// Service
|
||||||
|
// ──────────────────────────────────────────
|
||||||
|
class FeedbackService {
|
||||||
|
final Dio _dio;
|
||||||
|
FeedbackService(this._dio);
|
||||||
|
|
||||||
|
Future<void> submit(FeedbackRequest req) async {
|
||||||
|
await _dio.post<void>('/feedback', data: req.toJson());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final feedbackServiceProvider = Provider<FeedbackService>(
|
||||||
|
(ref) => FeedbackService(ref.read(apiClientProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────
|
||||||
|
// Estado del formulario
|
||||||
|
// ──────────────────────────────────────────
|
||||||
|
enum FeedbackFormStatus { idle, loading, success, error }
|
||||||
|
|
||||||
|
class FeedbackFormState {
|
||||||
|
final FeedbackType selectedType;
|
||||||
|
final int rating;
|
||||||
|
final String message;
|
||||||
|
final FeedbackFormStatus status;
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
const FeedbackFormState({
|
||||||
|
this.selectedType = FeedbackType.noPaso,
|
||||||
|
this.rating = 3,
|
||||||
|
this.message = '',
|
||||||
|
this.status = FeedbackFormStatus.idle,
|
||||||
|
this.errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
FeedbackFormState copyWith({
|
||||||
|
FeedbackType? selectedType,
|
||||||
|
int? rating,
|
||||||
|
String? message,
|
||||||
|
FeedbackFormStatus? status,
|
||||||
|
String? errorMessage,
|
||||||
|
}) {
|
||||||
|
return FeedbackFormState(
|
||||||
|
selectedType: selectedType ?? this.selectedType,
|
||||||
|
rating: rating ?? this.rating,
|
||||||
|
message: message ?? this.message,
|
||||||
|
status: status ?? this.status,
|
||||||
|
errorMessage: errorMessage ?? this.errorMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────
|
||||||
|
// Notifier
|
||||||
|
// ──────────────────────────────────────────
|
||||||
|
class FeedbackNotifier extends Notifier<FeedbackFormState> {
|
||||||
|
@override
|
||||||
|
FeedbackFormState build() => const FeedbackFormState();
|
||||||
|
|
||||||
|
void setType(FeedbackType type) => state = state.copyWith(selectedType: type);
|
||||||
|
|
||||||
|
void setRating(int r) => state = state.copyWith(rating: r);
|
||||||
|
|
||||||
|
void setMessage(String m) => state = state.copyWith(message: m);
|
||||||
|
|
||||||
|
void reset() => state = const FeedbackFormState();
|
||||||
|
|
||||||
|
Future<void> submit({
|
||||||
|
required String addressId,
|
||||||
|
required String unitId,
|
||||||
|
}) async {
|
||||||
|
state = state.copyWith(status: FeedbackFormStatus.loading);
|
||||||
|
try {
|
||||||
|
final req = FeedbackRequest(
|
||||||
|
addressId: addressId,
|
||||||
|
type: state.selectedType,
|
||||||
|
rating: state.rating,
|
||||||
|
message: state.message,
|
||||||
|
targetUnitId: unitId,
|
||||||
|
);
|
||||||
|
await ref.read(feedbackServiceProvider).submit(req);
|
||||||
|
state = state.copyWith(status: FeedbackFormStatus.success);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
final data = e.response?.data;
|
||||||
|
final detail = (data is Map && data['detail'] != null)
|
||||||
|
? data['detail'].toString()
|
||||||
|
: null;
|
||||||
|
state = state.copyWith(
|
||||||
|
status: FeedbackFormStatus.error,
|
||||||
|
errorMessage: detail ?? e.message ?? 'Error al enviar',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
state = state.copyWith(
|
||||||
|
status: FeedbackFormStatus.error,
|
||||||
|
errorMessage: 'Error al enviar: $e',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final feedbackProvider = NotifierProvider<FeedbackNotifier, FeedbackFormState>(
|
||||||
|
FeedbackNotifier.new,
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,17 +1,564 @@
|
|||||||
import 'package:flutter/material.dart';
|
// lib/features/feedback/feedback_screen.dart
|
||||||
|
// Buzón de retroalimentación. Expone "Unidad 101", nunca el nombre del chofer.
|
||||||
|
|
||||||
class FeedbackScreen extends StatelessWidget {
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../eta/eta_provider.dart'; // activeAddressIdProvider
|
||||||
|
import '../incidents/providers/incident_providers.dart'; // assignedUnitProvider
|
||||||
|
import 'feedback_model.dart';
|
||||||
|
import 'feedback_provider.dart';
|
||||||
|
|
||||||
|
class FeedbackScreen extends ConsumerStatefulWidget {
|
||||||
const FeedbackScreen({super.key});
|
const FeedbackScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<FeedbackScreen> createState() => _FeedbackScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FeedbackScreenState extends ConsumerState<FeedbackScreen> {
|
||||||
|
final _messageController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_messageController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final formState = ref.watch(feedbackProvider);
|
||||||
|
|
||||||
|
if (formState.status == FeedbackFormStatus.success) {
|
||||||
|
return _SuccessView(
|
||||||
|
onReset: () {
|
||||||
|
ref.read(feedbackProvider.notifier).reset();
|
||||||
|
_messageController.clear();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Retroalimentación')),
|
backgroundColor: AppTheme.background,
|
||||||
body: const Center(
|
body: CustomScrollView(
|
||||||
child: Text(
|
slivers: [
|
||||||
'TODO: Feedback Screen - Formulario de queja hacia la unidad',
|
SliverToBoxAdapter(child: _buildPageHeader(context)),
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildListDelegate([
|
||||||
|
// Tipo de reporte
|
||||||
|
_SectionLabel('Tipo de reporte'),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_TypeChips(
|
||||||
|
selected: formState.selectedType,
|
||||||
|
onSelect: (t) =>
|
||||||
|
ref.read(feedbackProvider.notifier).setType(t),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Rating
|
||||||
|
_SectionLabel('Calificación del servicio'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Center(
|
||||||
|
child: _StarRating(
|
||||||
|
rating: formState.rating,
|
||||||
|
onRate: (r) =>
|
||||||
|
ref.read(feedbackProvider.notifier).setRating(r),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Unidad (sin exponer chofer)
|
||||||
|
_SectionLabel('Unidad involucrada'),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
const _UnitBadge(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Mensaje libre
|
||||||
|
_SectionLabel('Descripción (opcional)'),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||||
|
border: Border.all(color: AppTheme.border),
|
||||||
|
boxShadow: AppTheme.softShadow,
|
||||||
|
),
|
||||||
|
child: TextField(
|
||||||
|
controller: _messageController,
|
||||||
|
maxLines: 4,
|
||||||
|
maxLength: 300,
|
||||||
|
onChanged: (v) =>
|
||||||
|
ref.read(feedbackProvider.notifier).setMessage(v),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Cuéntanos qué pasó...',
|
||||||
|
border: InputBorder.none,
|
||||||
|
contentPadding: EdgeInsets.all(14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Error
|
||||||
|
if (formState.status == FeedbackFormStatus.error)
|
||||||
|
_ErrorBanner(
|
||||||
|
message: formState.errorMessage ?? 'Error',
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
bottomNavigationBar: _buildSubmitButton(context, formState, ref),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPageHeader(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
20,
|
||||||
|
MediaQuery.of(context).padding.top + 12,
|
||||||
|
20,
|
||||||
|
24,
|
||||||
|
),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [Color(0xFF4A0E26), Color(0xFF9B1B4A)],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
bottomLeft: Radius.circular(28),
|
||||||
|
bottomRight: Radius.circular(28),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => Navigator.of(context).pop(),
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.arrow_back,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
const Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Buzón de retroalimentación',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'Tu opinión mejora el servicio',
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.white70),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.rate_review_outlined,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSubmitButton(
|
||||||
|
BuildContext context,
|
||||||
|
FeedbackFormState formState,
|
||||||
|
WidgetRef ref,
|
||||||
|
) {
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: formState.status == FeedbackFormStatus.loading
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
final addressId = ref.read(activeAddressIdProvider);
|
||||||
|
final unit = ref.read(assignedUnitProvider).value;
|
||||||
|
if (addressId == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Selecciona una dirección activa antes de enviar.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (unit == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Tu zona aún no tiene una unidad asignada.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ref.read(feedbackProvider.notifier).submit(
|
||||||
|
addressId: addressId,
|
||||||
|
unitId: unit.id.toString(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: formState.status == FeedbackFormStatus.loading
|
||||||
|
? const SizedBox(
|
||||||
|
height: 18,
|
||||||
|
width: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Text('Enviar reporte'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Chips de tipo ─────────────────────────────────────────────────────────────
|
||||||
|
class _TypeChips extends StatelessWidget {
|
||||||
|
final FeedbackType selected;
|
||||||
|
final ValueChanged<FeedbackType> onSelect;
|
||||||
|
const _TypeChips({required this.selected, required this.onSelect});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: FeedbackType.values.map((t) {
|
||||||
|
final isSelected = t == selected;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: ChoiceChip(
|
||||||
|
label: Text(t.label),
|
||||||
|
selected: isSelected,
|
||||||
|
onSelected: (_) => onSelect(t),
|
||||||
|
selectedColor: AppTheme.primaryLight,
|
||||||
|
backgroundColor: AppTheme.surface,
|
||||||
|
side: BorderSide(
|
||||||
|
color: isSelected ? AppTheme.primary : AppTheme.border,
|
||||||
|
width: isSelected ? 1.5 : 0.5,
|
||||||
|
),
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: isSelected ? AppTheme.primaryDark : AppTheme.textSecondary,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight:
|
||||||
|
isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stars ─────────────────────────────────────────────────────────────────────
|
||||||
|
class _StarRating extends StatelessWidget {
|
||||||
|
final int rating;
|
||||||
|
final ValueChanged<int> onRate;
|
||||||
|
const _StarRating({required this.rating, required this.onRate});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: List.generate(5, (i) {
|
||||||
|
final filled = i < rating;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => onRate(i + 1),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||||
|
child: Icon(
|
||||||
|
filled ? Icons.star_rounded : Icons.star_outline_rounded,
|
||||||
|
size: 42,
|
||||||
|
color: filled ? const Color(0xFFEF9F27) : AppTheme.border,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Badge de unidad (sin exponer chofer) ──────────────────────────────────────
|
||||||
|
class _UnitBadge extends ConsumerWidget {
|
||||||
|
const _UnitBadge();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final unitAsync = ref.watch(assignedUnitProvider);
|
||||||
|
final unitLabel = unitAsync.when(
|
||||||
|
loading: () => 'Detectando unidad…',
|
||||||
|
error: (_, _) => 'Unidad no disponible',
|
||||||
|
data: (u) => u == null ? 'Sin unidad asignada' : 'Unidad ${u.id}',
|
||||||
|
);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryLight,
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||||
|
border: Border.all(color: AppTheme.primaryMid),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.local_shipping_outlined,
|
||||||
|
size: 18,
|
||||||
|
color: AppTheme.primaryDark,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
unitLabel,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppTheme.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.shield_outlined,
|
||||||
|
size: 13,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Solo se registra el número de unidad. El operador no es identificado.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Error banner ──────────────────────────────────────────────────────────────
|
||||||
|
class _ErrorBanner extends StatelessWidget {
|
||||||
|
final String message;
|
||||||
|
const _ErrorBanner({required this.message});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.dangerLight,
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||||
|
border: Border.all(color: AppTheme.danger.withValues(alpha: 0.3)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 16, color: AppTheme.danger),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
message,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppTheme.danger,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Success view ──────────────────────────────────────────────────────────────
|
||||||
|
class _SuccessView extends StatelessWidget {
|
||||||
|
final VoidCallback onReset;
|
||||||
|
const _SuccessView({required this.onReset});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppTheme.background,
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
20,
|
||||||
|
MediaQuery.of(context).padding.top + 12,
|
||||||
|
20,
|
||||||
|
24,
|
||||||
|
),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [Color(0xFF4A0E26), Color(0xFF9B1B4A)],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
bottomLeft: Radius.circular(28),
|
||||||
|
bottomRight: Radius.circular(28),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => Navigator.of(context).pop(),
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.arrow_back,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
const Text(
|
||||||
|
'Buzón de retroalimentación',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppTheme.primaryLight,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.check_circle_outline_rounded,
|
||||||
|
size: 40,
|
||||||
|
color: AppTheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
const Text(
|
||||||
|
'Reporte enviado',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
const Text(
|
||||||
|
'Gracias. Tu retroalimentación ayuda a mejorar el servicio. El reporte fue registrado de forma anónima.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: onReset,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: AppTheme.primary,
|
||||||
|
side: const BorderSide(color: AppTheme.primary),
|
||||||
|
),
|
||||||
|
child: const Text('Enviar otro reporte'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
class _SectionLabel extends StatelessWidget {
|
||||||
|
final String text;
|
||||||
|
const _SectionLabel(this.text);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Text(
|
||||||
|
text.toUpperCase(),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,20 +48,14 @@ class _HelpFaqScreenState extends ConsumerState<HelpFaqScreen> {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppTheme.background,
|
backgroundColor: AppTheme.background,
|
||||||
appBar: AppBar(
|
body: Column(
|
||||||
title: const Text('Ayuda y preguntas frecuentes'),
|
children: [
|
||||||
actions: [
|
_GradientHeader(
|
||||||
IconButton(
|
hasMessages: state.messages.isNotEmpty,
|
||||||
tooltip: 'Reiniciar conversación',
|
onReset: state.messages.isEmpty
|
||||||
icon: const Icon(Icons.refresh),
|
|
||||||
onPressed: state.messages.isEmpty
|
|
||||||
? null
|
? null
|
||||||
: () => ref.read(helpChatControllerProvider.notifier).reset(),
|
: () => ref.read(helpChatControllerProvider.notifier).reset(),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
body: Column(
|
|
||||||
children: [
|
|
||||||
if (state.messages.isEmpty) _QuickQuestions(onSelect: _send),
|
if (state.messages.isEmpty) _QuickQuestions(onSelect: _send),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: state.messages.isEmpty
|
child: state.messages.isEmpty
|
||||||
@@ -80,7 +74,7 @@ class _HelpFaqScreenState extends ConsumerState<HelpFaqScreen> {
|
|||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
color: AppTheme.danger.withOpacity(0.1),
|
color: AppTheme.danger.withValues(alpha: 0.1),
|
||||||
child: Text(
|
child: Text(
|
||||||
state.error!,
|
state.error!,
|
||||||
style: const TextStyle(color: AppTheme.danger, fontSize: 13),
|
style: const TextStyle(color: AppTheme.danger, fontSize: 13),
|
||||||
@@ -97,20 +91,120 @@ class _HelpFaqScreenState extends ConsumerState<HelpFaqScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _GradientHeader extends StatelessWidget {
|
||||||
|
final bool hasMessages;
|
||||||
|
final VoidCallback? onReset;
|
||||||
|
const _GradientHeader({required this.hasMessages, this.onReset});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
stops: [0.0, 0.6, 1.0],
|
||||||
|
colors: [Color(0xFF4A0E26), Color(0xFF6D1234), Color(0xFF9B1B4A)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
bottom: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 4, 8, 18),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back_ios_new_rounded,
|
||||||
|
color: Colors.white, size: 20),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (hasMessages)
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Reiniciar conversación',
|
||||||
|
icon: const Icon(Icons.refresh_rounded,
|
||||||
|
color: Colors.white, size: 20),
|
||||||
|
onPressed: onReset,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.15),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.support_agent_rounded,
|
||||||
|
color: Colors.white, size: 22),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Ayuda y soporte',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Pregunta lo que necesites',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.white.withValues(alpha: 0.75),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _QuickQuestions extends StatelessWidget {
|
class _QuickQuestions extends StatelessWidget {
|
||||||
final ValueChanged<String> onSelect;
|
final ValueChanged<String> onSelect;
|
||||||
const _QuickQuestions({required this.onSelect});
|
const _QuickQuestions({required this.onSelect});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return Container(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: Theme.of(context).colorScheme.outlineVariant, width: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
child: Wrap(
|
child: Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
for (final q in _quickQuestions)
|
for (final q in _quickQuestions)
|
||||||
ActionChip(label: Text(q), onPressed: () => onSelect(q)),
|
ActionChip(
|
||||||
|
label: Text(q, style: const TextStyle(fontSize: 12)),
|
||||||
|
onPressed: () => onSelect(q),
|
||||||
|
backgroundColor: AppTheme.primaryLight,
|
||||||
|
side: const BorderSide(color: AppTheme.primary, width: 0.5),
|
||||||
|
labelStyle: const TextStyle(color: AppTheme.primaryDark),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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';
|
||||||
@@ -11,6 +12,7 @@ import '../../core/network/api_client.dart';
|
|||||||
import '../notifications/notification_service.dart';
|
import '../notifications/notification_service.dart';
|
||||||
import '../../shared/widgets/prevention_banner.dart';
|
import '../../shared/widgets/prevention_banner.dart';
|
||||||
import '../../shared/widgets/progress_steps.dart';
|
import '../../shared/widgets/progress_steps.dart';
|
||||||
|
import '../separation_guide/ai_pet_chat_screen.dart';
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Modelo de resultado ETA
|
// Modelo de resultado ETA
|
||||||
@@ -75,11 +77,19 @@ 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() {
|
||||||
|
// Consulta silenciosa cada 10 segundos para ver el avance en tiempo real
|
||||||
|
_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();
|
|
||||||
state = await AsyncValue.guard(_fetch);
|
state = await AsyncValue.guard(_fetch);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +137,20 @@ final etaProvider = AsyncNotifierProvider<_EtaNotifier, _EtaResult>(
|
|||||||
class ActiveRouteIdNotifier extends Notifier<String?> {
|
class ActiveRouteIdNotifier extends Notifier<String?> {
|
||||||
@override
|
@override
|
||||||
String? build() => null;
|
String? build() => null;
|
||||||
|
|
||||||
|
void set(String? value) {
|
||||||
|
debugPrint('📡 [FCM] Evaluando suscripción a la ruta: $value');
|
||||||
|
if (state != value) {
|
||||||
|
final oldRoute = state;
|
||||||
|
state = value;
|
||||||
|
if (oldRoute != null) NotificationService.unsubscribeFromRoute(oldRoute);
|
||||||
|
if (value != null) {
|
||||||
|
NotificationService.subscribeToRoute(
|
||||||
|
value,
|
||||||
|
).catchError((e) => debugPrint('❌ Error FCM: $e'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final activeRouteIdProvider = NotifierProvider<ActiveRouteIdNotifier, String?>(
|
final activeRouteIdProvider = NotifierProvider<ActiveRouteIdNotifier, String?>(
|
||||||
@@ -149,7 +173,7 @@ class _CitizenHomeScreenState extends ConsumerState<CitizenHomeScreen>
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
// Refresca al recibir push FCM (RUTA_PROXIMITY, ROUTE_START, etc.)
|
// Refresca al recibir push FCM (RUTA_PROXIMITY, ROUTE_START, etc.)F
|
||||||
NotificationService.onFcmMessage.addListener(_onPush);
|
NotificationService.onFcmMessage.addListener(_onPush);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,11 +266,15 @@ class _EtaContent extends StatelessWidget {
|
|||||||
const PreventionBanner(),
|
const PreventionBanner(),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// ── 5. Badge de suscripción FCM ─────────────────────────────────
|
// ── 5. Banner del Chat IA (Eco) ─────────────────────────────────
|
||||||
|
const _EcoChatBanner(),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// ── 6. Badge de suscripción FCM ─────────────────────────────────
|
||||||
const _FcmStatusBadge(),
|
const _FcmStatusBadge(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// ── 6. Horario semanal ──────────────────────────────────────────
|
// ── 7. Horario semanal ──────────────────────────────────────────
|
||||||
AppSectionTitle(title: 'Horario del camión'),
|
AppSectionTitle(title: 'Horario del camión'),
|
||||||
_HorarioCard(),
|
_HorarioCard(),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
@@ -256,6 +284,71 @@ class _EtaContent extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Banner de Eco (Chat IA)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
class _EcoChatBanner extends StatelessWidget {
|
||||||
|
const _EcoChatBanner();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const AiPetChatScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryDark,
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
||||||
|
boxShadow: AppTheme.softShadow,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white24,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.delete_outline,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
const Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'¿Dudas sobre reciclaje?',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Pregúntale a Eco, tu asistente inteligente',
|
||||||
|
style: TextStyle(color: Colors.white70, fontSize: 13),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(Icons.chevron_right, color: Colors.white),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Mapa de ubicación del domicilio (no interactivo)
|
// Mapa de ubicación del domicilio (no interactivo)
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -322,13 +415,13 @@ class _EtaHeroCard extends StatelessWidget {
|
|||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
if (result.isCompleted) return cs.surfaceContainerHighest;
|
if (result.isCompleted) return cs.surfaceContainerHighest;
|
||||||
if (result.isNearby) return const Color(0xFFFFF8E1); // amber-50
|
if (result.isNearby) return const Color(0xFFFFF8E1); // amber-50
|
||||||
return const Color(0xFFE1F5EE); // teal-50
|
return const Color(0xFFE8D5DB); // rosa claro institucional
|
||||||
}
|
}
|
||||||
|
|
||||||
Color _accentColor(BuildContext context) {
|
Color _accentColor(BuildContext context) {
|
||||||
if (result.isCompleted) return Theme.of(context).colorScheme.outline;
|
if (result.isCompleted) return Theme.of(context).colorScheme.outline;
|
||||||
if (result.isNearby) return const Color(0xFFBA7517); // amber-400
|
if (result.isNearby) return const Color(0xFFC8A36A); // beige dorado
|
||||||
return const Color(0xFF1D9E75); // teal-400
|
return const Color(0xFF9B1B4A); // vino principal
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -606,7 +699,7 @@ class _FcmStatusBadge extends ConsumerWidget {
|
|||||||
width: 8,
|
width: 8,
|
||||||
height: 8,
|
height: 8,
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: Color(0xFF1D9E75),
|
color: Color(0xFF1E7A46),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,13 +1,95 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../core/widgets/app_widgets.dart';
|
import '../../core/widgets/app_widgets.dart';
|
||||||
import '../../shared/widgets/eco_floating_button.dart';
|
import '../../shared/widgets/eco_floating_button.dart';
|
||||||
|
import '../notifications/notification_service.dart';
|
||||||
|
import '../alerts/alerts_provider.dart';
|
||||||
|
|
||||||
class CitizenShell extends StatelessWidget {
|
class CitizenShell extends ConsumerStatefulWidget {
|
||||||
const CitizenShell({super.key, required this.child});
|
const CitizenShell({super.key, required this.child});
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<CitizenShell> createState() => _CitizenShellState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CitizenShellState extends ConsumerState<CitizenShell> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
NotificationService.onFcmMessage.addListener(_onGlobalPush);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
NotificationService.onFcmMessage.removeListener(_onGlobalPush);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onGlobalPush() {
|
||||||
|
final msg = NotificationService.onFcmMessage.lastMessage;
|
||||||
|
if (msg?.notification != null) {
|
||||||
|
final title = msg!.notification!.title ?? 'Nueva Alerta';
|
||||||
|
final body = msg.notification!.body ?? '';
|
||||||
|
|
||||||
|
// Guardamos la alerta en el historial
|
||||||
|
ref.read(alertsProvider.notifier).addAlert(title, body);
|
||||||
|
|
||||||
|
// Mostramos un globo de notificación amigable dentro de la app
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
backgroundColor: AppTheme.primaryDark,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
margin: const EdgeInsets.all(16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 5),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.notifications_active,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
body,
|
||||||
|
style: const TextStyle(color: Colors.white70, fontSize: 13),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: 'VER',
|
||||||
|
textColor: AppTheme.primaryLight,
|
||||||
|
onPressed: () => context.go('/alerts'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
int _currentIndex(BuildContext context) {
|
int _currentIndex(BuildContext context) {
|
||||||
final location = GoRouterState.of(context).matchedLocation;
|
final location = GoRouterState.of(context).matchedLocation;
|
||||||
if (location.startsWith('/alerts')) return 1;
|
if (location.startsWith('/alerts')) return 1;
|
||||||
@@ -32,7 +114,7 @@ class CitizenShell extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: child,
|
body: widget.child,
|
||||||
floatingActionButton: const EcoFloatingButton(),
|
floatingActionButton: const EcoFloatingButton(),
|
||||||
bottomNavigationBar: AppBottomNav(
|
bottomNavigationBar: AppBottomNav(
|
||||||
currentIndex: _currentIndex(context),
|
currentIndex: _currentIndex(context),
|
||||||
|
|||||||
@@ -75,42 +75,50 @@ class _MyHouseScreenState extends State<MyHouseScreen> {
|
|||||||
if (_casa == null) {
|
if (_casa == null) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppTheme.background,
|
backgroundColor: AppTheme.background,
|
||||||
appBar: AppBar(title: const Text('Mi casa')),
|
body: Column(
|
||||||
body: const Center(child: Text('No tienes un domicilio registrado.')),
|
children: [
|
||||||
|
_buildPageHeader(context, showEdit: false),
|
||||||
|
const Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'No tienes un domicilio registrado.',
|
||||||
|
style: TextStyle(fontSize: 15, color: AppTheme.textSecondary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildAddressButton(context),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppTheme.background,
|
backgroundColor: AppTheme.background,
|
||||||
appBar: AppBar(
|
body: CustomScrollView(
|
||||||
title: const Text('Mi casa'),
|
slivers: [
|
||||||
actions: [
|
SliverToBoxAdapter(child: _buildPageHeader(context, showEdit: true)),
|
||||||
IconButton(
|
SliverPadding(
|
||||||
icon: const Icon(Icons.edit_outlined),
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
|
||||||
onPressed: () => _mostrarEditarDireccion(context),
|
sliver: SliverList(
|
||||||
tooltip: 'Editar dirección',
|
delegate: SliverChildListDelegate([
|
||||||
),
|
const AppSectionTitle(title: 'Domicilio registrado'),
|
||||||
],
|
|
||||||
),
|
|
||||||
body: ListView(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
children: [
|
|
||||||
_CasaCard(casa: _casa!),
|
_CasaCard(casa: _casa!),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 20),
|
||||||
const AppSectionTitle(title: 'Mapa del Sector (Restringido)'),
|
const AppSectionTitle(title: 'Mapa del Sector (Restringido)'),
|
||||||
_MapaColoniaRestringido(
|
_MapaColoniaRestringido(
|
||||||
colonia: _casa!.colonia,
|
colonia: _casa!.colonia,
|
||||||
lat: _casa!.lat,
|
lat: _casa!.lat,
|
||||||
lng: _casa!.lng,
|
lng: _casa!.lng,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 20),
|
||||||
const AppSectionTitle(title: 'Radio de alerta'),
|
const AppSectionTitle(title: 'Radio de alerta'),
|
||||||
_RadioAlertaCard(
|
_RadioAlertaCard(
|
||||||
radioActual: _casa!.radioAlertaMetros,
|
radioActual: _casa!.radioAlertaMetros,
|
||||||
onChanged: (v) =>
|
onChanged: (v) => setState(
|
||||||
setState(() => _casa = _casa!.copyWith(radioAlertaMetros: v)),
|
() => _casa = _casa!.copyWith(radioAlertaMetros: v),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
const AppSectionTitle(title: 'Notificaciones'),
|
const AppSectionTitle(title: 'Notificaciones'),
|
||||||
_NotificacionesCard(
|
_NotificacionesCard(
|
||||||
casa: _casa!,
|
casa: _casa!,
|
||||||
@@ -118,52 +126,119 @@ class _MyHouseScreenState extends State<MyHouseScreen> {
|
|||||||
setState(() => _casa = _casa!.copyWith(alertaCercana: v)),
|
setState(() => _casa = _casa!.copyWith(alertaCercana: v)),
|
||||||
onAlertaMediaChanged: (v) =>
|
onAlertaMediaChanged: (v) =>
|
||||||
setState(() => _casa = _casa!.copyWith(alertaMedia: v)),
|
setState(() => _casa = _casa!.copyWith(alertaMedia: v)),
|
||||||
onRecordatorioChanged: (v) =>
|
onRecordatorioChanged: (v) => setState(
|
||||||
setState(() => _casa = _casa!.copyWith(recordatorioDiario: v)),
|
() => _casa = _casa!.copyWith(recordatorioDiario: v),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
const AppSectionTitle(title: 'Horario del camión'),
|
const AppSectionTitle(title: 'Horario del camión'),
|
||||||
_HorarioCard(),
|
_HorarioCard(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 24),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(child: _buildAddressButton(context)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPageHeader(BuildContext context, {required bool showEdit}) {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
20,
|
||||||
|
MediaQuery.of(context).padding.top + 12,
|
||||||
|
20,
|
||||||
|
24,
|
||||||
|
),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [Color(0xFF4A0E26), Color(0xFF9B1B4A)],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
bottomLeft: Radius.circular(28),
|
||||||
|
bottomRight: Radius.circular(28),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.home_outlined,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
const Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Mi Casa',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'Domicilio registrado',
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.white70),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showEdit)
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () async {
|
onTap: () => _mostrarEditarDireccion(context),
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.edit_outlined,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAddressButton(BuildContext context) {
|
||||||
|
return SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 12, 24, 96),
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
final added = await context.push<bool>('/add-address');
|
final added = await context.push<bool>('/add-address');
|
||||||
if (added == true && mounted) {
|
if (added == true && mounted) {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
_cargarDomicilio();
|
_cargarDomicilio();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Container(
|
icon: const Icon(Icons.add_home_outlined, size: 20),
|
||||||
padding: const EdgeInsets.all(16),
|
label: const Text('Agregar otra dirección'),
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.surface,
|
|
||||||
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
|
||||||
border: Border.all(color: AppTheme.primaryMid),
|
|
||||||
boxShadow: AppTheme.softShadow,
|
|
||||||
),
|
|
||||||
child: const Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.add_home_outlined,
|
|
||||||
color: AppTheme.primary,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
'Agregar otra dirección',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: AppTheme.primary,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -191,13 +266,22 @@ class _CasaCard extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.surface,
|
color: AppTheme.surface,
|
||||||
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
||||||
border: Border.all(color: AppTheme.primaryMid, width: 0.8),
|
border: Border.all(color: AppTheme.border, width: 0.5),
|
||||||
boxShadow: AppTheme.softShadow,
|
boxShadow: AppTheme.softShadow,
|
||||||
),
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusLg - 0.5),
|
||||||
|
child: IntrinsicHeight(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Container(width: 3, color: AppTheme.primary),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -230,7 +314,9 @@ class _CasaCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
AppStatusBadge.green(casa.activa ? 'Activa' : 'Inactiva'),
|
AppStatusBadge.green(
|
||||||
|
casa.activa ? 'Activa' : 'Inactiva',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -246,10 +332,17 @@ class _CasaCard extends StatelessWidget {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_DetailRow(
|
_DetailRow(
|
||||||
icon: Icons.radar_outlined,
|
icon: Icons.radar_outlined,
|
||||||
text: 'Alerta a ${casa.radioAlertaMetros} m de distancia',
|
text:
|
||||||
|
'Alerta a ${casa.radioAlertaMetros} m de distancia',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,14 +356,16 @@ class _MapaColoniaRestringido extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final center = kColoniaCenter(colonia);
|
final center =
|
||||||
|
kColoniasCoordinates[colonia] ?? const LatLng(20.5222, -100.8123);
|
||||||
final pin = (lat != null && lng != null) ? LatLng(lat!, lng!) : center;
|
final pin = (lat != null && lng != null) ? LatLng(lat!, lng!) : center;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
height: 200,
|
height: 220,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
||||||
border: Border.all(color: AppTheme.border, width: 1),
|
border: Border.all(color: AppTheme.border, width: 1),
|
||||||
|
boxShadow: AppTheme.softShadow,
|
||||||
),
|
),
|
||||||
clipBehavior: Clip.hardEdge,
|
clipBehavior: Clip.hardEdge,
|
||||||
child: FlutterMap(
|
child: FlutterMap(
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../core/widgets/app_widgets.dart';
|
import '../../core/widgets/app_widgets.dart';
|
||||||
|
import '../notifications/notification_service.dart';
|
||||||
|
import '../alerts/alerts_provider.dart';
|
||||||
import 'citizen_home_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';
|
||||||
|
|
||||||
class MainShell extends StatefulWidget {
|
class MainShell extends ConsumerStatefulWidget {
|
||||||
const MainShell({super.key});
|
const MainShell({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<MainShell> createState() => _MainShellState();
|
ConsumerState<MainShell> createState() => _MainShellState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MainShellState extends State<MainShell> {
|
class _MainShellState extends ConsumerState<MainShell> {
|
||||||
int _currentIndex = 0;
|
int _currentIndex = 0;
|
||||||
|
|
||||||
static const List<Widget> _screens = [
|
static const List<Widget> _screens = [
|
||||||
@@ -22,6 +27,77 @@ class _MainShellState extends State<MainShell> {
|
|||||||
ProfileScreen(),
|
ProfileScreen(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
NotificationService.onFcmMessage.addListener(_onGlobalPush);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
NotificationService.onFcmMessage.removeListener(_onGlobalPush);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onGlobalPush() {
|
||||||
|
final msg = NotificationService.onFcmMessage.lastMessage;
|
||||||
|
if (msg?.notification != null) {
|
||||||
|
final title = msg!.notification!.title ?? 'Nueva Alerta';
|
||||||
|
final body = msg.notification!.body ?? '';
|
||||||
|
|
||||||
|
ref.read(alertsProvider.notifier).addAlert(title, body);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
backgroundColor: AppTheme.primaryDark,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
margin: const EdgeInsets.all(16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 5),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.notifications_active,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
body,
|
||||||
|
style: const TextStyle(color: Colors.white70, fontSize: 13),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: 'VER',
|
||||||
|
textColor: AppTheme.primaryLight,
|
||||||
|
onPressed: () => setState(() => _currentIndex = 1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|||||||
@@ -16,13 +16,38 @@ class IncidentService {
|
|||||||
final Dio _dio;
|
final Dio _dio;
|
||||||
|
|
||||||
Future<List<UnitOption>> listUnits() async {
|
Future<List<UnitOption>> listUnits() async {
|
||||||
final res = await _dio.get<List<dynamic>>('/incidents/units');
|
final res = await _dio.get<List<dynamic>>(
|
||||||
|
'/incidents/units',
|
||||||
|
options: Options(
|
||||||
|
receiveTimeout: const Duration(seconds: 6),
|
||||||
|
sendTimeout: const Duration(seconds: 6),
|
||||||
|
),
|
||||||
|
);
|
||||||
return (res.data ?? [])
|
return (res.data ?? [])
|
||||||
.whereType<Map>()
|
.whereType<Map>()
|
||||||
.map((e) => UnitOption.fromJson(Map<String, dynamic>.from(e)))
|
.map((e) => UnitOption.fromJson(Map<String, dynamic>.from(e)))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Devuelve la unidad asignada al domicilio (vía su ruta).
|
||||||
|
/// `null` si el backend responde 404 (sin ruta o sin unidad).
|
||||||
|
Future<UnitOption?> getAddressUnit(String addressId) async {
|
||||||
|
try {
|
||||||
|
final res = await _dio.get<Map<String, dynamic>>(
|
||||||
|
'/addresses/$addressId/unit',
|
||||||
|
options: Options(
|
||||||
|
receiveTimeout: const Duration(seconds: 6),
|
||||||
|
sendTimeout: const Duration(seconds: 6),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (res.data == null) return null;
|
||||||
|
return UnitOption.fromJson(res.data!);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
if (e.response?.statusCode == 404) return null;
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<IncidentReport> createIncident({
|
Future<IncidentReport> createIncident({
|
||||||
required String category,
|
required String category,
|
||||||
required String description,
|
required String description,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../eta/eta_provider.dart';
|
||||||
import '../data/incident_service.dart';
|
import '../data/incident_service.dart';
|
||||||
import '../models/incident.dart';
|
import '../models/incident.dart';
|
||||||
|
|
||||||
@@ -10,3 +11,13 @@ final unitsProvider = FutureProvider<List<UnitOption>>((ref) async {
|
|||||||
final myIncidentsProvider = FutureProvider<List<IncidentReport>>((ref) async {
|
final myIncidentsProvider = FutureProvider<List<IncidentReport>>((ref) async {
|
||||||
return ref.read(incidentServiceProvider).myIncidents();
|
return ref.read(incidentServiceProvider).myIncidents();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Unidad asignada al domicilio activo del ciudadano.
|
||||||
|
/// Se deriva en backend a partir de `addresses.route_id → routes.truck_id`.
|
||||||
|
/// Devuelve `null` si el ciudadano aún no tiene una dirección activa
|
||||||
|
/// o si su ruta no tiene unidad asignada.
|
||||||
|
final assignedUnitProvider = FutureProvider<UnitOption?>((ref) async {
|
||||||
|
final addressId = ref.watch(activeAddressIdProvider);
|
||||||
|
if (addressId == null) return null;
|
||||||
|
return ref.read(incidentServiceProvider).getAddressUnit(addressId);
|
||||||
|
});
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ class _ReportIssueScreenState extends ConsumerState<ReportIssueScreen> {
|
|||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final _descCtrl = TextEditingController();
|
final _descCtrl = TextEditingController();
|
||||||
|
|
||||||
int? _unitId;
|
|
||||||
String _category = 'no_recoleccion';
|
String _category = 'no_recoleccion';
|
||||||
File? _photo;
|
File? _photo;
|
||||||
bool _submitting = false;
|
bool _submitting = false;
|
||||||
@@ -49,12 +48,13 @@ class _ReportIssueScreenState extends ConsumerState<ReportIssueScreen> {
|
|||||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||||
setState(() => _submitting = true);
|
setState(() => _submitting = true);
|
||||||
try {
|
try {
|
||||||
|
final assignedUnit = ref.read(assignedUnitProvider).value;
|
||||||
await ref
|
await ref
|
||||||
.read(incidentServiceProvider)
|
.read(incidentServiceProvider)
|
||||||
.createIncident(
|
.createIncident(
|
||||||
category: _category,
|
category: _category,
|
||||||
description: _descCtrl.text.trim(),
|
description: _descCtrl.text.trim(),
|
||||||
unitId: _unitId,
|
unitId: assignedUnit?.id,
|
||||||
photo: _photo,
|
photo: _photo,
|
||||||
);
|
);
|
||||||
ref.invalidate(myIncidentsProvider);
|
ref.invalidate(myIncidentsProvider);
|
||||||
@@ -76,81 +76,50 @@ class _ReportIssueScreenState extends ConsumerState<ReportIssueScreen> {
|
|||||||
String _friendly(Object e) {
|
String _friendly(Object e) {
|
||||||
if (e is DioException) {
|
if (e is DioException) {
|
||||||
final data = e.response?.data;
|
final data = e.response?.data;
|
||||||
if (data is Map && data['detail'] != null)
|
if (data is Map && data['detail'] != null) {
|
||||||
return data['detail'].toString();
|
return data['detail'].toString();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return 'No se pudo enviar el reporte';
|
return 'No se pudo enviar el reporte';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final unitsAsync = ref.watch(unitsProvider);
|
final assignedUnitAsync = ref.watch(assignedUnitProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppTheme.background,
|
backgroundColor: AppTheme.background,
|
||||||
appBar: AppBar(title: const Text('Reportar un problema')),
|
body: Form(
|
||||||
body: SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Form(
|
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: AppCard(
|
child: CustomScrollView(
|
||||||
child: Column(
|
slivers: [
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
SliverToBoxAdapter(child: _buildPageHeader(context)),
|
||||||
children: [
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildListDelegate([
|
||||||
const AppSectionTitle(title: 'Detalles del reporte'),
|
const AppSectionTitle(title: 'Detalles del reporte'),
|
||||||
|
AppFormCard(
|
||||||
// Unidad
|
icon: Icons.local_shipping_outlined,
|
||||||
Text(
|
title: 'Unidad asignada a tu zona',
|
||||||
'Unidad relacionada (opcional)',
|
child: _AssignedUnitBadge(
|
||||||
style: const TextStyle(
|
assignedUnitAsync: assignedUnitAsync,
|
||||||
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),
|
const SizedBox(height: 16),
|
||||||
|
AppFormCard(
|
||||||
// Categoría
|
icon: Icons.category_outlined,
|
||||||
Text(
|
title: 'Categoría del problema',
|
||||||
'Categoría',
|
child: DropdownButtonFormField<String>(
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: AppTheme.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
DropdownButtonFormField<String>(
|
|
||||||
initialValue: _category,
|
initialValue: _category,
|
||||||
isExpanded: true,
|
isExpanded: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
contentPadding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
items: [
|
items: [
|
||||||
for (final entry in incidentCategories.entries)
|
for (final entry in incidentCategories.entries)
|
||||||
DropdownMenuItem<String>(
|
DropdownMenuItem<String>(
|
||||||
@@ -165,40 +134,60 @@ class _ReportIssueScreenState extends ConsumerState<ReportIssueScreen> {
|
|||||||
? 'Selecciona una categoría'
|
? 'Selecciona una categoría'
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
AppFormCard(
|
||||||
AppFormField(
|
icon: Icons.description_outlined,
|
||||||
label: 'Descripción',
|
title: 'Descripción',
|
||||||
|
child: AppFormField(
|
||||||
|
label: 'Cuéntanos qué pasó',
|
||||||
controller: _descCtrl,
|
controller: _descCtrl,
|
||||||
hint: 'Cuéntanos qué pasó…',
|
hint: 'Cuéntanos qué pasó…',
|
||||||
maxLines: 5,
|
maxLines: 5,
|
||||||
keyboardType: TextInputType.multiline,
|
keyboardType: TextInputType.multiline,
|
||||||
validator: (v) {
|
validator: (v) {
|
||||||
final t = (v ?? '').trim();
|
final t = (v ?? '').trim();
|
||||||
if (t.length < 3)
|
if (t.length < 3) {
|
||||||
return 'Describe el problema (mínimo 3 caracteres)';
|
return 'Describe el problema (mínimo 3 caracteres)';
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
AppFormCard(
|
||||||
// Foto
|
icon: Icons.photo_camera_outlined,
|
||||||
|
title: 'Evidencia fotográfica',
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
OutlinedButton.icon(
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
onPressed: _submitting ? null : _pickPhoto,
|
onPressed: _submitting ? null : _pickPhoto,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: AppTheme.primary,
|
||||||
|
side: const BorderSide(
|
||||||
|
color: AppTheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
icon: const Icon(Icons.photo_camera_outlined),
|
icon: const Icon(Icons.photo_camera_outlined),
|
||||||
label: Text(
|
label: Text(
|
||||||
_photo == null ? 'Adjuntar foto' : 'Cambiar foto',
|
_photo == null
|
||||||
|
? 'Adjuntar foto'
|
||||||
|
: 'Cambiar foto',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_photo != null) ...[
|
if (_photo != null) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Quitar foto',
|
tooltip: 'Quitar foto',
|
||||||
icon: const Icon(Icons.close, color: AppTheme.danger),
|
icon: const Icon(
|
||||||
|
Icons.close,
|
||||||
|
color: AppTheme.danger,
|
||||||
|
),
|
||||||
onPressed: _submitting
|
onPressed: _submitting
|
||||||
? null
|
? null
|
||||||
: () => setState(() => _photo = null),
|
: () => setState(() => _photo = null),
|
||||||
@@ -209,7 +198,8 @@ class _ReportIssueScreenState extends ConsumerState<ReportIssueScreen> {
|
|||||||
if (_photo != null) ...[
|
if (_photo != null) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
borderRadius:
|
||||||
|
BorderRadius.circular(AppTheme.radiusMd),
|
||||||
child: Image.file(
|
child: Image.file(
|
||||||
_photo!,
|
_photo!,
|
||||||
height: 180,
|
height: 180,
|
||||||
@@ -218,10 +208,101 @@ class _ReportIssueScreenState extends ConsumerState<ReportIssueScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
bottomNavigationBar: _buildSubmitButton(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const SizedBox(height: 20),
|
Widget _buildPageHeader(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
20,
|
||||||
|
MediaQuery.of(context).padding.top + 12,
|
||||||
|
20,
|
||||||
|
24,
|
||||||
|
),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [Color(0xFF4A0E26), Color(0xFF9B1B4A)],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
bottomLeft: Radius.circular(28),
|
||||||
|
bottomRight: Radius.circular(28),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => Navigator.of(context).pop(),
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.arrow_back,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
const Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Reportar un problema',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'Ayúdanos a mejorar el servicio',
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.white70),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.bug_report_outlined,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
SizedBox(
|
Widget _buildSubmitButton() {
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
|
||||||
|
child: SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: _submitting ? null : _submit,
|
onPressed: _submitting ? null : _submit,
|
||||||
@@ -229,16 +310,123 @@ class _ReportIssueScreenState extends ConsumerState<ReportIssueScreen> {
|
|||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
height: 18,
|
height: 18,
|
||||||
width: 18,
|
width: 18,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: const Text('Enviar reporte'),
|
: const Text('Enviar reporte'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _AssignedUnitBadge extends StatelessWidget {
|
||||||
|
const _AssignedUnitBadge({required this.assignedUnitAsync});
|
||||||
|
|
||||||
|
final AsyncValue<UnitOption?> assignedUnitAsync;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return assignedUnitAsync.when(
|
||||||
|
loading: () => Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.background,
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||||
|
border: Border.all(color: AppTheme.border),
|
||||||
|
),
|
||||||
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: 14,
|
||||||
|
width: 14,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
'Detectando unidad asignada…',
|
||||||
|
style: TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
error: (_, _) => Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.dangerLight,
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||||
|
border: Border.all(color: AppTheme.danger.withValues(alpha: 0.3)),
|
||||||
|
),
|
||||||
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline, size: 16, color: AppTheme.danger),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'No se pudo obtener la unidad asignada. El reporte se enviará sin unidad.',
|
||||||
|
style: TextStyle(fontSize: 12, color: AppTheme.danger),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
data: (unit) {
|
||||||
|
if (unit == null) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.amberLight,
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppTheme.amber.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.info_outline, size: 16, color: AppTheme.amber),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Tu zona aún no tiene una unidad asignada.',
|
||||||
|
style: TextStyle(fontSize: 13, color: AppTheme.amber),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryLight,
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||||
|
border: Border.all(color: AppTheme.primaryMid),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.local_shipping_outlined,
|
||||||
|
size: 18,
|
||||||
|
color: AppTheme.primaryDark,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
unit.label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppTheme.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,11 @@
|
|||||||
|
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../core/router/app_router.dart';
|
||||||
|
|
||||||
// Canal Android de alta prioridad para alertas de proximidad
|
// Canal Android de alta prioridad para alertas de proximidad
|
||||||
const _kChannelId = 'recolecta_alerts';
|
const _kChannelId = 'recolecta_alerts';
|
||||||
@@ -58,7 +62,8 @@ class NotificationService {
|
|||||||
);
|
);
|
||||||
await _localNotifications
|
await _localNotifications
|
||||||
.resolvePlatformSpecificImplementation<
|
.resolvePlatformSpecificImplementation<
|
||||||
AndroidFlutterLocalNotificationsPlugin>()
|
AndroidFlutterLocalNotificationsPlugin
|
||||||
|
>()
|
||||||
?.createNotificationChannel(androidChannel);
|
?.createNotificationChannel(androidChannel);
|
||||||
|
|
||||||
// Inicializar flutter_local_notifications
|
// Inicializar flutter_local_notifications
|
||||||
@@ -66,7 +71,13 @@ class NotificationService {
|
|||||||
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
|
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
|
||||||
iOS: DarwinInitializationSettings(),
|
iOS: DarwinInitializationSettings(),
|
||||||
);
|
);
|
||||||
await _localNotifications.initialize(initSettings);
|
await _localNotifications.initialize(
|
||||||
|
initSettings,
|
||||||
|
onDidReceiveNotificationResponse: (response) {
|
||||||
|
// Tap del banner mostrado en foreground por flutter_local_notifications
|
||||||
|
_openNotificationsScreen();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Foreground: mostrar notificación local + notificar EtaScreen
|
// Foreground: mostrar notificación local + notificar EtaScreen
|
||||||
FirebaseMessaging.onMessage.listen((message) {
|
FirebaseMessaging.onMessage.listen((message) {
|
||||||
@@ -77,12 +88,32 @@ class NotificationService {
|
|||||||
// Tap en notificación cuando la app estaba en background
|
// Tap en notificación cuando la app estaba en background
|
||||||
FirebaseMessaging.onMessageOpenedApp.listen((message) {
|
FirebaseMessaging.onMessageOpenedApp.listen((message) {
|
||||||
onFcmMessage.notify(message);
|
onFcmMessage.notify(message);
|
||||||
|
_openNotificationsScreen();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verificar si la app abrió desde una notificación (terminated)
|
// Verificar si la app abrió desde una notificación (terminated)
|
||||||
final initial = await _messaging.getInitialMessage();
|
final initial = await _messaging.getInitialMessage();
|
||||||
if (initial != null) {
|
if (initial != null) {
|
||||||
onFcmMessage.notify(initial);
|
onFcmMessage.notify(initial);
|
||||||
|
// Esperar a que el router termine de montar el árbol antes de navegar.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_openNotificationsScreen();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navega a `/notifications` usando el Navigator raíz. Tolerante a que
|
||||||
|
/// la app aún no esté montada (no hace nada en ese caso).
|
||||||
|
static void _openNotificationsScreen() {
|
||||||
|
final ctx = rootNavigatorKey.currentContext;
|
||||||
|
if (ctx == null) {
|
||||||
|
debugPrint('[FCM] tap recibido pero el navigator aún no está listo');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
GoRouter.of(ctx).push('/notifications');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[FCM] no se pudo navegar a /notifications: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,13 @@ class NotificationsNotifier extends Notifier<List<NotificationItem>> {
|
|||||||
state = [item, ...state];
|
state = [item, ...state];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void addSimulationEvent(String title, String body, FcmEventType type) {
|
||||||
|
state = [
|
||||||
|
NotificationItem(title: title, body: body, type: type, receivedAt: DateTime.now()),
|
||||||
|
...state,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
void clearAll() => state = [];
|
void clearAll() => state = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +155,7 @@ class _FcmTopicBadge extends StatelessWidget {
|
|||||||
width: 8,
|
width: 8,
|
||||||
height: 8,
|
height: 8,
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: Color(0xFF1D9E75),
|
color: Color(0xFF1E7A46),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -188,7 +195,7 @@ class _PrivacyNote extends StatelessWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFFAEEDA), // amber-50
|
color: const Color(0xFFF5EDD8), // beige dorado claro
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
border: Border.all(color: const Color(0xFFFAC775)),
|
border: Border.all(color: const Color(0xFFFAC775)),
|
||||||
),
|
),
|
||||||
@@ -196,12 +203,12 @@ class _PrivacyNote extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.info_outline_rounded,
|
const Icon(Icons.info_outline_rounded,
|
||||||
size: 18, color: Color(0xFFBA7517)),
|
size: 18, color: Color(0xFFC8A36A)),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Los mensajes no revelan la ubicación del camión. Solo se muestra el tiempo estimado de llegada.',
|
'Los mensajes no revelan la ubicación del camión. Solo se muestra el tiempo estimado de llegada.',
|
||||||
style: const TextStyle(fontSize: 12, color: Color(0xFF633806)),
|
style: const TextStyle(fontSize: 12, color: Color(0xFF7A5410)),
|
||||||
maxLines: 3,
|
maxLines: 3,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -254,13 +261,13 @@ class _NotificationCard extends StatelessWidget {
|
|||||||
Color _accentColor() {
|
Color _accentColor() {
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case FcmEventType.routeStart:
|
case FcmEventType.routeStart:
|
||||||
return const Color(0xFF1D9E75);
|
return const Color(0xFF9B1B4A);
|
||||||
case FcmEventType.truckProximity:
|
case FcmEventType.truckProximity:
|
||||||
return const Color(0xFFBA7517);
|
return const Color(0xFFC8A36A);
|
||||||
case FcmEventType.routeCompleted:
|
case FcmEventType.routeCompleted:
|
||||||
return Colors.grey;
|
return const Color(0xFF1E7A46);
|
||||||
case FcmEventType.reassignment:
|
case FcmEventType.reassignment:
|
||||||
return const Color(0xFF378ADD);
|
return const Color(0xFF004A7C);
|
||||||
default:
|
default:
|
||||||
return Colors.grey;
|
return Colors.grey;
|
||||||
}
|
}
|
||||||
@@ -279,17 +286,24 @@ class _NotificationCard extends StatelessWidget {
|
|||||||
final accent = _accentColor();
|
final accent = _accentColor();
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surface,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
border: Border(
|
border: Border.all(
|
||||||
left: BorderSide(color: accent, width: 3),
|
color: Theme.of(context).colorScheme.outlineVariant,
|
||||||
top: BorderSide(color: Theme.of(context).colorScheme.outlineVariant, width: 0.5),
|
width: 0.5,
|
||||||
right: BorderSide(color: Theme.of(context).colorScheme.outlineVariant, width: 0.5),
|
|
||||||
bottom: BorderSide(color: Theme.of(context).colorScheme.outlineVariant, width: 0.5),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(9.5),
|
||||||
|
child: IntrinsicHeight(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Container(width: 3, color: accent),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -297,7 +311,7 @@ class _NotificationCard extends StatelessWidget {
|
|||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: accent.withOpacity(0.1),
|
color: accent.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Icon(_icon, size: 16, color: accent),
|
child: Icon(_icon, size: 16, color: accent),
|
||||||
@@ -336,6 +350,12 @@ class _NotificationCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ class _EditProfileScreenState extends ConsumerState<EditProfileScreen> {
|
|||||||
_prefilled = true;
|
_prefilled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normaliza un teléfono almacenado (con o sin lada/guiones) al formato 000-000-0000
|
|
||||||
String _formatPhoneInitial(String? raw) {
|
String _formatPhoneInitial(String? raw) {
|
||||||
if (raw == null || raw.isEmpty) return '';
|
if (raw == null || raw.isEmpty) return '';
|
||||||
final digits = raw.replaceAll(RegExp(r'\D'), '');
|
final digits = raw.replaceAll(RegExp(r'\D'), '');
|
||||||
@@ -132,7 +131,6 @@ class _EditProfileScreenState extends ConsumerState<EditProfileScreen> {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppTheme.background,
|
backgroundColor: AppTheme.background,
|
||||||
appBar: AppBar(title: const Text('Editar perfil')),
|
|
||||||
body: userAsync.when(
|
body: userAsync.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) => Center(
|
error: (e, _) => Center(
|
||||||
@@ -154,17 +152,24 @@ class _EditProfileScreenState extends ConsumerState<EditProfileScreen> {
|
|||||||
|
|
||||||
return Form(
|
return Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: ListView(
|
child: CustomScrollView(
|
||||||
padding: const EdgeInsets.all(16),
|
slivers: [
|
||||||
children: [
|
SliverToBoxAdapter(child: _buildPageHeader(context)),
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildListDelegate([
|
||||||
const AppSectionTitle(title: 'Datos personales'),
|
const AppSectionTitle(title: 'Datos personales'),
|
||||||
AppCard(
|
AppFormCard(
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
title: 'Información personal',
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
AppFormField(
|
AppFormField(
|
||||||
label: 'Nombre',
|
label: 'Nombre',
|
||||||
controller: _nameCtrl,
|
controller: _nameCtrl,
|
||||||
validator: (v) => (v == null || v.trim().isEmpty)
|
validator: (v) =>
|
||||||
|
(v == null || v.trim().isEmpty)
|
||||||
? 'Ingresa tu nombre'
|
? 'Ingresa tu nombre'
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
@@ -186,7 +191,9 @@ class _EditProfileScreenState extends ConsumerState<EditProfileScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
const AppSectionTitle(title: 'Cambiar contraseña'),
|
const AppSectionTitle(title: 'Cambiar contraseña'),
|
||||||
AppCard(
|
AppFormCard(
|
||||||
|
icon: Icons.lock_outline,
|
||||||
|
title: 'Seguridad',
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
AppFormField(
|
AppFormField(
|
||||||
@@ -224,7 +231,9 @@ class _EditProfileScreenState extends ConsumerState<EditProfileScreen> {
|
|||||||
if (v == null || v.isEmpty) {
|
if (v == null || v.isEmpty) {
|
||||||
return 'Confirma la contraseña';
|
return 'Confirma la contraseña';
|
||||||
}
|
}
|
||||||
if (v != _newPasswordCtrl.text) return 'No coincide';
|
if (v != _newPasswordCtrl.text) {
|
||||||
|
return 'No coincide';
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -242,8 +251,110 @@ class _EditProfileScreenState extends ConsumerState<EditProfileScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 32),
|
||||||
SizedBox(
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
bottomNavigationBar: _buildSaveButton(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPageHeader(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
20,
|
||||||
|
MediaQuery.of(context).padding.top + 12,
|
||||||
|
20,
|
||||||
|
24,
|
||||||
|
),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [Color(0xFF4A0E26), Color(0xFF9B1B4A)],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
bottomLeft: Radius.circular(28),
|
||||||
|
bottomRight: Radius.circular(28),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => Navigator.of(context).pop(),
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.arrow_back,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
const Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Editar perfil',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'Actualiza tu información personal',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Colors.white70,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _saving ? null : _save,
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: _saving
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.all(8),
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.save_outlined, color: Colors.white, size: 20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSaveButton() {
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
|
||||||
|
child: SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: _saving ? null : _save,
|
onPressed: _saving ? null : _save,
|
||||||
@@ -251,16 +362,14 @@ class _EditProfileScreenState extends ConsumerState<EditProfileScreen> {
|
|||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
height: 20,
|
height: 20,
|
||||||
width: 20,
|
width: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: const Text('Guardar cambios'),
|
: const Text('Guardar cambios'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -369,7 +478,7 @@ class _PhoneField extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
validator: (v) {
|
validator: (v) {
|
||||||
if (v == null || v.isEmpty) return null; // opcional
|
if (v == null || v.isEmpty) return null;
|
||||||
final digits = v.replaceAll('-', '');
|
final digits = v.replaceAll('-', '');
|
||||||
if (digits.length != 10) {
|
if (digits.length != 10) {
|
||||||
return 'Ingresa exactamente 10 dígitos';
|
return 'Ingresa exactamente 10 dígitos';
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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 '../separation_guide/ai_pet_chat_screen.dart';
|
|
||||||
import 'models/profile_user.dart';
|
import 'models/profile_user.dart';
|
||||||
import 'providers/profile_providers.dart';
|
import 'providers/profile_providers.dart';
|
||||||
|
|
||||||
@@ -20,22 +19,25 @@ class ProfileScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppTheme.background,
|
backgroundColor: AppTheme.background,
|
||||||
appBar: AppBar(title: const Text('Mi perfil')),
|
|
||||||
body: RefreshIndicator(
|
body: RefreshIndicator(
|
||||||
|
color: AppTheme.primary,
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
ref.invalidate(currentUserProvider);
|
ref.invalidate(currentUserProvider);
|
||||||
await ref.read(currentUserProvider.future);
|
await ref.read(currentUserProvider.future);
|
||||||
},
|
},
|
||||||
child: ListView(
|
child: CustomScrollView(
|
||||||
padding: const EdgeInsets.all(16),
|
slivers: [
|
||||||
children: [
|
SliverToBoxAdapter(
|
||||||
_ProfileHeader(
|
child: _ProfileHeroHeader(
|
||||||
user: userAsync.asData?.value,
|
user: userAsync.asData?.value,
|
||||||
fallbackRole: authRole,
|
fallbackRole: authRole,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
),
|
||||||
|
SliverPadding(
|
||||||
const AppSectionTitle(title: 'Mi cuenta'),
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildListDelegate([
|
||||||
|
const AppSectionTitle(title: 'Cuenta'),
|
||||||
AppMenuTile(
|
AppMenuTile(
|
||||||
icon: Icons.person_outline,
|
icon: Icons.person_outline,
|
||||||
title: 'Editar perfil',
|
title: 'Editar perfil',
|
||||||
@@ -52,18 +54,16 @@ class ProfileScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const AppSectionTitle(title: 'Soporte'),
|
const AppSectionTitle(title: 'Herramientas'),
|
||||||
AppMenuTile(
|
AppMenuTile(
|
||||||
icon: Icons.pets,
|
icon: Icons.feedback_outlined,
|
||||||
title: 'Hablar con Eco (Asistente IA)',
|
title: 'Buzón de retroalimentación',
|
||||||
subtitle: 'Guía de separación de residuos',
|
subtitle: 'Califica el servicio de recolección',
|
||||||
onTap: () {
|
onTap: () => context.push('/feedback'),
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(builder: (_) => const AiPetChatScreen()),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const AppSectionTitle(title: 'Soporte'),
|
||||||
AppMenuTile(
|
AppMenuTile(
|
||||||
icon: Icons.help_outline,
|
icon: Icons.help_outline,
|
||||||
title: 'Ayuda y preguntas frecuentes',
|
title: 'Ayuda y preguntas frecuentes',
|
||||||
@@ -105,6 +105,9 @@ class ProfileScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -157,11 +160,11 @@ class ProfileScreen extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Encabezado ────────────────────────────────────────────────────────────────
|
// ── Hero header con gradiente ─────────────────────────────────────────────────
|
||||||
class _ProfileHeader extends StatelessWidget {
|
class _ProfileHeroHeader extends StatelessWidget {
|
||||||
final ProfileUser? user;
|
final ProfileUser? user;
|
||||||
final String fallbackRole;
|
final String fallbackRole;
|
||||||
const _ProfileHeader({required this.user, required this.fallbackRole});
|
const _ProfileHeroHeader({required this.user, required this.fallbackRole});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -171,66 +174,89 @@ class _ProfileHeader extends StatelessWidget {
|
|||||||
final initials = user?.initials ?? 'U';
|
final initials = user?.initials ?? 'U';
|
||||||
final displayName = user?.displayName ?? 'Usuario';
|
final displayName = user?.displayName ?? 'Usuario';
|
||||||
final email = user?.email ?? '…';
|
final email = user?.email ?? '…';
|
||||||
|
final roleLabel = isAdmin
|
||||||
|
? 'Administrador'
|
||||||
|
: isDriver
|
||||||
|
? 'Chofer'
|
||||||
|
: 'Ciudadano';
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: EdgeInsets.fromLTRB(
|
||||||
decoration: BoxDecoration(
|
24,
|
||||||
color: AppTheme.surface,
|
MediaQuery.of(context).padding.top + 20,
|
||||||
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
24,
|
||||||
border: Border.all(color: AppTheme.border, width: 0.5),
|
32,
|
||||||
boxShadow: AppTheme.softShadow,
|
|
||||||
),
|
),
|
||||||
child: Row(
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [Color(0xFF4A0E26), Color(0xFF9B1B4A)],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
bottomLeft: Radius.circular(28),
|
||||||
|
bottomRight: Radius.circular(28),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
// Avatar con iniciales
|
||||||
Container(
|
Container(
|
||||||
width: 56,
|
width: 80,
|
||||||
height: 56,
|
height: 80,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.primaryLight,
|
color: Colors.white.withValues(alpha: 0.15),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(color: AppTheme.primaryMid, width: 1.5),
|
border: Border.all(
|
||||||
|
color: Colors.white.withValues(alpha: 0.4),
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
initials,
|
initials,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 28,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppTheme.primaryDark,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(height: 14),
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
Text(
|
||||||
displayName,
|
displayName,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppTheme.textPrimary,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
email,
|
email,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: AppTheme.textSecondary,
|
color: Colors.white.withValues(alpha: 0.8),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 10),
|
||||||
AppStatusBadge.green(
|
Container(
|
||||||
isAdmin
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 5),
|
||||||
? 'Administrador'
|
decoration: BoxDecoration(
|
||||||
: isDriver
|
color: Colors.white.withValues(alpha: 0.18),
|
||||||
? 'Chofer'
|
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
|
||||||
: 'Ciudadano',
|
border: Border.all(
|
||||||
|
color: Colors.white.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
roleLabel,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -238,4 +264,3 @@ class _ProfileHeader extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'dart:math';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../auth/widgets/video_mascot.dart';
|
||||||
|
|
||||||
class SplashScreen extends StatefulWidget {
|
class SplashScreen extends StatefulWidget {
|
||||||
const SplashScreen({super.key});
|
const SplashScreen({super.key});
|
||||||
|
|
||||||
@@ -38,9 +40,10 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
duration: const Duration(seconds: 6),
|
duration: const Duration(seconds: 6),
|
||||||
)..repeat();
|
)..repeat();
|
||||||
|
|
||||||
_logoScale = Tween<double>(begin: 0.2, end: 1.0).animate(
|
_logoScale = Tween<double>(
|
||||||
CurvedAnimation(parent: _logoCtrl, curve: Curves.elasticOut),
|
begin: 0.5,
|
||||||
);
|
end: 1.0,
|
||||||
|
).animate(CurvedAnimation(parent: _logoCtrl, curve: Curves.easeOutBack));
|
||||||
_logoOpacity = Tween<double>(begin: 0.0, end: 1.0).animate(
|
_logoOpacity = Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||||
CurvedAnimation(
|
CurvedAnimation(
|
||||||
parent: _logoCtrl,
|
parent: _logoCtrl,
|
||||||
@@ -95,11 +98,7 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
stops: [0.0, 0.55, 1.0],
|
stops: [0.0, 0.55, 1.0],
|
||||||
colors: [
|
colors: [Color(0xFF4A0E26), Color(0xFF6D1234), Color(0xFF9B1B4A)],
|
||||||
Color(0xFF0A4A38),
|
|
||||||
Color(0xFF0F6E56),
|
|
||||||
Color(0xFF1D9E75),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
@@ -114,7 +113,11 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
),
|
),
|
||||||
|
|
||||||
SafeArea(
|
SafeArea(
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
child: Column(
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Spacer(flex: 3),
|
const Spacer(flex: 3),
|
||||||
|
|
||||||
@@ -124,15 +127,8 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
child: FadeTransition(
|
child: FadeTransition(
|
||||||
opacity: _logoOpacity,
|
opacity: _logoOpacity,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 118,
|
|
||||||
height: 118,
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withValues(alpha: 0.15),
|
shape: BoxShape.circle,
|
||||||
borderRadius: BorderRadius.circular(34),
|
|
||||||
border: Border.all(
|
|
||||||
color: Colors.white.withValues(alpha: 0.35),
|
|
||||||
width: 2,
|
|
||||||
),
|
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.2),
|
color: Colors.black.withValues(alpha: 0.2),
|
||||||
@@ -141,11 +137,7 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const VideoMascot(size: 130, zoom: 6.5),
|
||||||
Icons.recycling_rounded,
|
|
||||||
size: 64,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -199,6 +191,7 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,22 +1,12 @@
|
|||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import 'app/app.dart';
|
import 'app/app.dart';
|
||||||
|
import 'features/notifications/notification_service.dart';
|
||||||
import 'firebase_options.dart';
|
import 'firebase_options.dart';
|
||||||
|
|
||||||
@pragma('vm:entry-point')
|
|
||||||
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
|
||||||
try {
|
|
||||||
await Firebase.initializeApp(
|
|
||||||
options: DefaultFirebaseOptions.currentPlatform,
|
|
||||||
);
|
|
||||||
} catch (_) {}
|
|
||||||
debugPrint('FCM background: ${message.messageId} | data: ${message.data}');
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
try {
|
try {
|
||||||
@@ -31,6 +21,8 @@ Future<void> main() async {
|
|||||||
} on UnsupportedError {
|
} on UnsupportedError {
|
||||||
await Firebase.initializeApp();
|
await Firebase.initializeApp();
|
||||||
}
|
}
|
||||||
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
|
// Registra handlers FCM (foreground + background), pide permisos
|
||||||
|
// POST_NOTIFICATIONS y crea el canal Android `recolecta_alerts`.
|
||||||
|
await NotificationService.initialize();
|
||||||
runApp(const ProviderScope(child: RecolectaApp()));
|
runApp(const ProviderScope(child: RecolectaApp()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,13 +55,14 @@ class EcoFloatingButton extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
clipBehavior: Clip.hardEdge,
|
clipBehavior: Clip.hardEdge,
|
||||||
child: Transform.scale(
|
child: Transform.scale(
|
||||||
scale: 1.5, // Ajusta este número si quieres el GIF más grande o pequeño
|
scale:
|
||||||
|
1.5, // Ajusta este número si quieres el GIF más grande o pequeño
|
||||||
child: Image.asset(
|
child: Image.asset(
|
||||||
'assets/animations/info.gif',
|
'assets/animations/info.gif',
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (context, error, stackTrace) {
|
errorBuilder: (context, error, stackTrace) {
|
||||||
return const Icon(
|
return const Icon(
|
||||||
Icons.pets,
|
Icons.delete_outline,
|
||||||
color: AppTheme.primary,
|
color: AppTheme.primary,
|
||||||
size: 32,
|
size: 32,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class PreventionBanner extends StatelessWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFFAEEDA), // amber-50
|
color: const Color(0xFFF5EDD8), // amber-50
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
border: Border.all(color: const Color(0xFFFAC775)),
|
border: Border.all(color: const Color(0xFFFAC775)),
|
||||||
),
|
),
|
||||||
@@ -27,7 +27,7 @@ class PreventionBanner extends StatelessWidget {
|
|||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.warning_amber_rounded,
|
Icons.warning_amber_rounded,
|
||||||
size: 18,
|
size: 18,
|
||||||
color: Color(0xFFBA7517),
|
color: Color(0xFFC8A36A),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -39,7 +39,7 @@ class PreventionBanner extends StatelessWidget {
|
|||||||
'No persigas ni detengas la unidad recolectora.',
|
'No persigas ni detengas la unidad recolectora.',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: Color(0xFF633806),
|
color: Color(0xFF7A5410),
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class ProgressSteps extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.route_rounded,
|
const Icon(Icons.route_rounded,
|
||||||
size: 16, color: Color(0xFF1D9E75)),
|
size: 16, color: Color(0xFF9B1B4A)),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
'Progreso del servicio',
|
'Progreso del servicio',
|
||||||
@@ -97,13 +97,13 @@ class _StepRow extends StatelessWidget {
|
|||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case _Status.done:
|
case _Status.done:
|
||||||
iconBg = const Color(0xFFE1F5EE);
|
iconBg = const Color(0xFFE8D5DB);
|
||||||
iconColor = const Color(0xFF1D9E75);
|
iconColor = const Color(0xFF9B1B4A);
|
||||||
displayIcon = Icons.check_rounded;
|
displayIcon = Icons.check_rounded;
|
||||||
break;
|
break;
|
||||||
case _Status.active:
|
case _Status.active:
|
||||||
iconBg = const Color(0xFFFAEEDA);
|
iconBg = const Color(0xFFF5EDD8);
|
||||||
iconColor = const Color(0xFFBA7517);
|
iconColor = const Color(0xFFC8A36A);
|
||||||
displayIcon = data.icon;
|
displayIcon = data.icon;
|
||||||
break;
|
break;
|
||||||
case _Status.pending:
|
case _Status.pending:
|
||||||
@@ -154,7 +154,7 @@ class _StepRow extends StatelessWidget {
|
|||||||
padding:
|
padding:
|
||||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFFAEEDA),
|
color: const Color(0xFFF5EDD8),
|
||||||
borderRadius: BorderRadius.circular(100),
|
borderRadius: BorderRadius.circular(100),
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: const Text(
|
||||||
@@ -162,7 +162,7 @@ class _StepRow extends StatelessWidget {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: Color(0xFF633806),
|
color: Color(0xFF7A5410),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user