simulacion de estados y flujo de notificacion, modificacion de estilos en todas las vistas

This commit is contained in:
shinra32
2026-05-23 07:08:49 -06:00
parent ca076607c7
commit 92f570294a
43 changed files with 4335 additions and 2035 deletions

View File

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

View File

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

View 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

View 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

View File

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

View File

@@ -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 18) 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')}")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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