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,34 +59,65 @@ 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
antes = pos if pos >= 8:
ahora = pos + 1 ESTADO[route_id] = 1
ESTADO[route_id] = ahora STATUS[route_id] = "PENDIENTE"
evt = None print(f"[SIM RESET] {route_id} reiniciado para nuevo ciclo.")
if antes == 1 and ahora == 2: continue
evt = "ROUTE_START"
elif ahora == 4:
evt = "TRUCK_PROXIMITY"
elif ahora == 8:
evt = "ROUTE_COMPLETED"
if evt: antes = pos
notif = _find_notif(evt) ahora = pos + 1
payload = notif.get("pushPayload") if notif else {"title": evt, "body": ""} ESTADO[route_id] = ahora
simulated = {"routeId": route_id, "event": evt, "payload": payload}
events.append(simulated) # Actualizar STATUS según la nueva posición
LAST_EVENTS.append(simulated) if ahora == 2:
# Enviar push vía servicio de notificaciones (FCM) o mock STATUS[route_id] = "en_ruta"
topic = f"topic_{route_id}" elif ahora == 8:
try: STATUS[route_id] = "completada"
notifications.send_to_topic(topic, payload)
except Exception: evt = None
print(f"[SIM PUSH FAIL] {route_id} -> {evt}: {payload.get('title')} - {payload.get('body')}") if antes == 1 and ahora == 2:
evt = "ROUTE_START"
elif ahora == 4:
evt = "TRUCK_PROXIMITY"
elif ahora == 8:
evt = "ROUTE_COMPLETED"
if evt:
notif = _find_notif(evt)
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}
events.append(simulated)
LAST_EVENTS.append(simulated)
topic = f"topic_{route_id}"
try:
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:
print(f"[SIM PUSH FAIL] {route_id} -> {evt}: {payload.get('title')} - {payload.get('body')}")
return events return events

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 dangerLight = Color(0xFFFCEBEB); static const Color accent = Color(0xFF1E7A46);
static const Color accentLight = Color(0xFFDFF0E8);
static const Color textPrimary = Color(0xFF1A1A1A); // ── Peligro ────────────────────────────────────────────────────────────────
static const Color danger = Color(0xFFD93040);
static const Color dangerLight = Color(0xFFFCEBEB);
// ── 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);
static const Color surface = Color(0xFFFFFFFF); // ── Superficies ────────────────────────────────────────────────────────────
static const Color background = Color(0xFFF5F7F5); static const Color surface = Color(0xFFFFFFFF);
static const Color border = Color(0xFFE5E7EB); static const Color background = Color(0xFFF8F8F8);
static const Color borderLight = Color(0xFFF0F2F0); static const Color border = Color(0xFFE0D5D8);
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,14 +79,29 @@ 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: child, 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,
),
),
],
),
),
),
), ),
); );
} }
@@ -110,50 +125,65 @@ 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: Row( child: ClipRRect(
children: [ borderRadius: BorderRadius.circular(AppTheme.radiusLg - 0.5),
Container( child: IntrinsicHeight(
width: 40, child: Row(
height: 40, crossAxisAlignment: CrossAxisAlignment.stretch,
decoration: BoxDecoration( children: [
color: AppTheme.primaryLight, Container(width: 3, color: AppTheme.primary),
borderRadius: BorderRadius.circular(10), Expanded(
), child: Padding(
child: Icon(icon, color: AppTheme.primary, size: 20), padding: const EdgeInsets.all(14),
), child: Row(
const SizedBox(width: 12), children: [
Expanded( Container(
child: Column( width: 40,
crossAxisAlignment: CrossAxisAlignment.start, height: 40,
children: [ decoration: BoxDecoration(
Text( color: AppTheme.primaryLight,
value, borderRadius: BorderRadius.circular(10),
style: const TextStyle( ),
fontSize: 14, child: Icon(icon, color: AppTheme.primary, size: 20),
fontWeight: FontWeight.w500, ),
color: AppTheme.textPrimary, const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
value,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 2),
Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
),
?trailing,
],
), ),
), ),
const SizedBox(height: 2), ),
Text( ],
label,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
), ),
?trailing, ),
],
), ),
); );
} }
@@ -305,53 +335,69 @@ class AppMenuTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return Material(
onTap: onTap, color: Colors.transparent,
child: Container( child: InkWell(
margin: const EdgeInsets.only(bottom: 8), onTap: onTap,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), borderRadius: BorderRadius.circular(AppTheme.radiusMd),
decoration: BoxDecoration( child: Container(
color: AppTheme.surface, margin: const EdgeInsets.only(bottom: 8),
borderRadius: BorderRadius.circular(AppTheme.radiusMd), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
border: Border.all(color: AppTheme.border, width: 0.5), decoration: BoxDecoration(
boxShadow: AppTheme.softShadow, color: AppTheme.surface,
), borderRadius: BorderRadius.circular(AppTheme.radiusMd),
child: Row( border: Border.all(color: AppTheme.border, width: 0.5),
children: [ boxShadow: AppTheme.softShadow,
Icon(icon, color: iconColor ?? AppTheme.primary, size: 20), ),
const SizedBox(width: 12), child: Row(
Expanded( children: [
child: Column( Container(
crossAxisAlignment: CrossAxisAlignment.start, width: 38,
children: [ height: 38,
Text( decoration: BoxDecoration(
title, color: (iconColor ?? AppTheme.primary).withValues(alpha: 0.1),
style: TextStyle( borderRadius: BorderRadius.circular(AppTheme.radiusSm),
fontSize: 14, ),
fontWeight: FontWeight.w500, child: Icon(
color: titleColor ?? AppTheme.textPrimary, icon,
), color: iconColor ?? AppTheme.primary,
), size: 20,
if (subtitle != null) ...[ ),
const SizedBox(height: 2), ),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text( Text(
subtitle!, title,
style: const TextStyle( style: TextStyle(
fontSize: 12, fontSize: 14,
color: AppTheme.textSecondary, fontWeight: FontWeight.w500,
color: titleColor ?? AppTheme.textPrimary,
), ),
), ),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(
subtitle!,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
], ],
],
),
),
trailing ??
const Icon(
Icons.chevron_right,
color: AppTheme.textSecondary,
size: 18,
), ),
], ),
trailing ??
const Icon(
Icons.chevron_right,
color: AppTheme.textSecondary,
size: 18,
),
],
),
), ),
), ),
); );
@@ -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: Column( child: ClipRRect(
crossAxisAlignment: CrossAxisAlignment.start, borderRadius: BorderRadius.circular(AppTheme.radiusLg - 0.5),
children: [ child: IntrinsicHeight(
Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Icon(icon, color: AppTheme.primary, size: 18), Container(width: 3, color: AppTheme.primary),
const SizedBox(width: 8), Expanded(
Text( child: Column(
title, crossAxisAlignment: CrossAxisAlignment.start,
style: const TextStyle( children: [
fontSize: 14, Container(
fontWeight: FontWeight.w600, width: double.infinity,
color: AppTheme.textPrimary, padding: const EdgeInsets.fromLTRB(16, 14, 16, 12),
color: AppTheme.primaryLight,
child: Row(
children: [
Icon(icon, color: AppTheme.primaryDark, size: 18),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppTheme.primaryDark,
),
),
],
),
),
Padding(
padding: const EdgeInsets.all(16),
child: child,
),
],
), ),
), ),
], ],
), ),
const SizedBox(height: 16), ),
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(
Icons.recycling_rounded,
size: 40,
color: AppTheme.primaryDark,
),
),
const SizedBox(height: 12),
const Text(
'Recolecta',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 4),
Text(
'Versión $version (build $build)',
style: const TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
),
),
],
),
), ),
const SizedBox(height: 16), SliverPadding(
AppCard( padding: const EdgeInsets.fromLTRB(20, 24, 20, 32),
child: Column( sliver: SliverList(
crossAxisAlignment: CrossAxisAlignment.start, delegate: SliverChildListDelegate([
children: const [ _SectionLabel('Descripción'),
AppSectionTitle(title: 'Acerca de'), const SizedBox(height: 10),
Text( _InfoCard(
'Recolecta es una aplicación del Servicio de Limpia de Celaya ' icon: Icons.info_outline_rounded,
'para informar al ciudadano sobre rutas, horarios y separación ' content: 'RecolectApp es una aplicación del Servicio de Limpia de Celaya '
'correcta de residuos.', 'para informar al ciudadano sobre rutas, horarios y separación '
style: TextStyle( 'correcta de residuos.',
fontSize: 14, ),
height: 1.5, const SizedBox(height: 20),
color: AppTheme.textPrimary, _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),
_TechRow(icon: Icons.phone_android_rounded, label: 'Flutter · Dart'),
const SizedBox(height: 8),
_TechRow(icon: Icons.cloud_outlined, label: 'FastAPI · Supabase'),
const SizedBox(height: 8),
_TechRow(icon: Icons.notifications_outlined, label: 'Firebase Cloud Messaging'),
const SizedBox(height: 32),
Center(
child: Text(
'© 2025 RecolectApp · Todos los derechos reservados',
style: const TextStyle(fontSize: 11, color: AppTheme.textHint),
textAlign: TextAlign.center,
), ),
), ),
], ]),
),
),
const SizedBox(height: 16),
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
AppSectionTitle(title: 'Créditos'),
Text(
'Desarrollado por el equipo ONLINCESHACK.\n'
'Servicio de Limpia · Celaya, Gto.',
style: TextStyle(
fontSize: 14,
height: 1.5,
color: AppTheme.textPrimary,
),
),
],
),
),
const SizedBox(height: 24),
const Center(
child: Text(
'© 2025 Recolecta',
style: TextStyle(fontSize: 12, color: AppTheme.textHint),
), ),
), ),
], ],
@@ -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,202 +213,279 @@ 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, AppFormCard(
fontSize: 16, icon: Icons.home_outlined,
fontWeight: FontWeight.w600, title: 'Dirección de tu casa',
), child: Column(
), children: [
), AppFormField(
body: SingleChildScrollView( label: 'Etiqueta',
padding: const EdgeInsets.all(24), hint: 'Ej. Mi Casa, Trabajo',
child: Column( controller: _labelCtrl,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppFormCard(
icon: Icons.home_outlined,
title: 'Dirección de tu casa',
child: Column(
children: [
AppFormField(
label: 'Etiqueta',
hint: 'Ej. Mi Casa, Trabajo',
controller: _labelCtrl,
),
const SizedBox(height: 14),
AppFormField(
label: 'Código Postal',
hint: 'Ej. 38000',
controller: _cpCtrl,
keyboardType: TextInputType.number,
onChanged: (v) => _validarCP(v, coloniasList),
),
if (_selectedColonia != null) ...[
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.primaryLight.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
border: Border.all(color: AppTheme.primaryMid),
), ),
child: Column( const SizedBox(height: 14),
crossAxisAlignment: CrossAxisAlignment.start, AppFormField(
children: [ label: 'Código Postal',
Row( hint: 'Ej. 38000',
controller: _cpCtrl,
keyboardType: TextInputType.number,
onChanged: (v) => _validarCP(v, coloniasList),
),
if (_selectedColonia != null) ...[
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.primaryLight.withValues(alpha: 0.5),
borderRadius:
BorderRadius.circular(AppTheme.radiusSm),
border: Border.all(color: AppTheme.primaryMid),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Icon( Row(
Icons.check_circle_outline, children: [
color: AppTheme.primary, const Icon(
size: 18, Icons.check_circle_outline,
color: AppTheme.primary,
size: 18,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Colonia: ${_selectedColonia!.nombre}',
style: const TextStyle(
fontWeight: FontWeight.w600,
color: AppTheme.primaryDark,
),
),
),
],
), ),
const SizedBox(width: 8), if (_selectedColonia!.horarioEstimado !=
Expanded( null) ...[
child: Text( const SizedBox(height: 8),
'Colonia: ${_selectedColonia!.nombre}', Text(
'Horario ${_selectedColonia!.turno?.toLowerCase() ?? ''}',
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.w600, fontSize: 13,
color: AppTheme.primaryDark, color: AppTheme.textPrimary,
), ),
), ),
), Text(
], _selectedColonia!.horarioEstimado!,
), style: const TextStyle(
if (_selectedColonia!.horarioEstimado != null) ...[ fontSize: 14,
const SizedBox(height: 8), fontWeight: FontWeight.bold,
Text( color: AppTheme.textPrimary,
'Horario ${_selectedColonia!.turno?.toLowerCase() ?? ''}',
style: const TextStyle(
fontSize: 13,
color: AppTheme.textPrimary,
),
),
Text(
_selectedColonia!.horarioEstimado!,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
],
],
),
),
const SizedBox(height: 14),
AppFormField(
label: 'Calle y número',
hint: 'Av. Insurgentes 245',
controller: _calleCtrl,
),
const SizedBox(height: 16),
const Text(
'Toca el mapa para ubicar tu casa exacta:',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
Container(
height: 200,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
border: Border.all(color: AppTheme.border),
),
clipBehavior: Clip.hardEdge,
child: FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCenter: mapCenter,
initialZoom: 15.0,
cameraConstraint: bounds != null
? CameraConstraint.containCenter(bounds: bounds)
: const CameraConstraint.unconstrained(),
onTap: (_, latlng) => _fetchStreetName(latlng),
),
children: [
TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.onlineshack.recolecta',
),
if (_selectedLocation != null)
MarkerLayer(
markers: [
Marker(
point: _selectedLocation!,
width: 40,
height: 40,
child: const Icon(
Icons.location_on,
color: AppTheme.danger,
size: 40,
), ),
), ),
], ],
),
],
),
),
] else ...[
const SizedBox(height: 24),
const Center(
child: Text(
'Ingresa un código postal con servicio\npara asignar tu colonia.',
textAlign: TextAlign.center,
style: TextStyle(
color: AppTheme.textSecondary,
fontSize: 13,
),
),
),
],
],
),
),
const SizedBox(height: 28),
SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: _loading ? null : _guardar,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: _loading
? const SizedBox(
key: ValueKey('loading'),
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const FittedBox(
key: ValueKey('text'),
fit: BoxFit.scaleDown,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.check, size: 18),
SizedBox(width: 8),
Text('Guardar dirección'),
], ],
), ),
), ),
const SizedBox(height: 14),
AppFormField(
label: 'Calle y número',
hint: 'Av. Insurgentes 245',
controller: _calleCtrl,
),
const SizedBox(height: 16),
const Text(
'Toca el mapa para ubicar tu casa exacta:',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
Container(
height: 220,
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(AppTheme.radiusMd),
border: Border.all(color: AppTheme.border),
boxShadow: AppTheme.softShadow,
),
clipBehavior: Clip.hardEdge,
child: FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCenter: mapCenter,
initialZoom: 15.0,
cameraConstraint: bounds != null
? CameraConstraint.containCenter(
bounds: bounds)
: const CameraConstraint.unconstrained(),
onTap: (_, latlng) => _fetchStreetName(latlng),
),
children: [
TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName:
'com.onlineshack.recolecta',
),
if (_selectedLocation != null)
MarkerLayer(
markers: [
Marker(
point: _selectedLocation!,
width: 40,
height: 40,
child: const Icon(
Icons.location_on,
color: AppTheme.danger,
size: 40,
),
),
],
),
],
),
),
] else ...[
const SizedBox(height: 24),
const Center(
child: Text(
'Ingresa un código postal con servicio\npara asignar tu colonia.',
textAlign: TextAlign.center,
style: TextStyle(
color: AppTheme.textSecondary,
fontSize: 13,
),
),
),
],
],
),
), ),
const SizedBox(height: 32),
]),
),
),
],
),
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(height: 24), ),
], 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,
child: ElevatedButton(
onPressed: _loading ? null : _guardar,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: _loading
? const SizedBox(
key: ValueKey('loading'),
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Row(
key: ValueKey('text'),
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check, size: 18),
SizedBox(width: 8),
Text('Guardar dirección'),
],
),
),
),
), ),
), ),
); );

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)), padding: const EdgeInsets.all(16),
child: ListView( itemCount: alerts.length,
padding: const EdgeInsets.all(16), separatorBuilder: (_, __) => const SizedBox(height: 12),
children: [ itemBuilder: (context, index) {
_AlertaActivaCard(alerta: _alertaActiva), return _AlertCard(alert: alerts[index]);
const SizedBox(height: 20), },
if (_historial.isEmpty) ),
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,
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), SizedBox(height: 16),
const Text('El camión se encuentra a', Text(
style: TextStyle(fontSize: 13, color: AppTheme.primaryDark)), 'No tienes notificaciones nuevas',
Row( style: TextStyle(
crossAxisAlignment: CrossAxisAlignment.end, fontSize: 16,
children: [ fontWeight: FontWeight.w600,
Text( color: AppTheme.textPrimary,
alerta.distanciaTexto,
style: const TextStyle(
fontSize: 36,
fontWeight: FontWeight.w700,
color: AppTheme.primary,
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,108 +67,77 @@ 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_active_outlined,
color: AppTheme.primary,
size: 24,
), ),
child: const Icon(Icons.notifications_outlined,
color: AppTheme.textSecondary, size: 18),
), ),
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(
style: const TextStyle( alert.title,
fontSize: 14, style: const TextStyle(
fontWeight: FontWeight.w500, fontSize: 15,
color: AppTheme.textPrimary)), fontWeight: FontWeight.w700,
const SizedBox(height: 2), color: AppTheme.primaryDark,
Text(alerta.fechaFormateada, ),
style: const TextStyle( ),
fontSize: 12, color: AppTheme.textSecondary)), const SizedBox(height: 4),
Text(
alert.body,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textPrimary,
height: 1.4,
),
),
const SizedBox(height: 8),
Text(
_timeAgo(alert.timestamp),
style: const TextStyle(
fontSize: 12,
color: AppTheme.textHint,
),
),
], ],
), ),
), ),
_EtiquetaDia(texto: alerta.etiquetaFecha),
],
),
);
}
}
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(
'Te notificaremos cuando el camión\nesté cerca de tu casa.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13, color: AppTheme.textSecondary, height: 1.5),
),
], ],
), ),
); );

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(

File diff suppressed because it is too large Load Diff

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,95 +75,170 @@ 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'),
_CasaCard(casa: _casa!),
const SizedBox(height: 20),
const AppSectionTitle(title: 'Mapa del Sector (Restringido)'),
_MapaColoniaRestringido(
colonia: _casa!.colonia,
lat: _casa!.lat,
lng: _casa!.lng,
),
const SizedBox(height: 20),
const AppSectionTitle(title: 'Radio de alerta'),
_RadioAlertaCard(
radioActual: _casa!.radioAlertaMetros,
onChanged: (v) => setState(
() => _casa = _casa!.copyWith(radioAlertaMetros: v),
),
),
const SizedBox(height: 20),
const AppSectionTitle(title: 'Notificaciones'),
_NotificacionesCard(
casa: _casa!,
onAlertaCercanaChanged: (v) =>
setState(() => _casa = _casa!.copyWith(alertaCercana: v)),
onAlertaMediaChanged: (v) =>
setState(() => _casa = _casa!.copyWith(alertaMedia: v)),
onRecordatorioChanged: (v) => setState(
() => _casa = _casa!.copyWith(recordatorioDiario: v),
),
),
const SizedBox(height: 20),
const AppSectionTitle(title: 'Horario del camión'),
_HorarioCard(),
const SizedBox(height: 24),
]),
),
), ),
SliverToBoxAdapter(child: _buildAddressButton(context)),
], ],
), ),
body: ListView( );
padding: const EdgeInsets.all(16), }
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: [ children: [
_CasaCard(casa: _casa!), Container(
const SizedBox(height: 16), width: 44,
const AppSectionTitle(title: 'Mapa del Sector (Restringido)'), height: 44,
_MapaColoniaRestringido( decoration: BoxDecoration(
colonia: _casa!.colonia, color: Colors.white.withValues(alpha: 0.15),
lat: _casa!.lat, borderRadius: BorderRadius.circular(12),
lng: _casa!.lng, ),
child: const Icon(
Icons.home_outlined,
color: Colors.white,
size: 24,
),
), ),
const SizedBox(height: 16), const SizedBox(width: 14),
const AppSectionTitle(title: 'Radio de alerta'), const Expanded(
_RadioAlertaCard( child: Column(
radioActual: _casa!.radioAlertaMetros, crossAxisAlignment: CrossAxisAlignment.start,
onChanged: (v) => children: [
setState(() => _casa = _casa!.copyWith(radioAlertaMetros: v)), 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),
),
],
),
), ),
const SizedBox(height: 16), if (showEdit)
const AppSectionTitle(title: 'Notificaciones'), GestureDetector(
_NotificacionesCard( onTap: () => _mostrarEditarDireccion(context),
casa: _casa!, child: Container(
onAlertaCercanaChanged: (v) => width: 36,
setState(() => _casa = _casa!.copyWith(alertaCercana: v)), height: 36,
onAlertaMediaChanged: (v) => decoration: BoxDecoration(
setState(() => _casa = _casa!.copyWith(alertaMedia: v)), color: Colors.white.withValues(alpha: 0.15),
onRecordatorioChanged: (v) => borderRadius: BorderRadius.circular(10),
setState(() => _casa = _casa!.copyWith(recordatorioDiario: v)), ),
), child: const Icon(
const SizedBox(height: 16), Icons.edit_outlined,
const AppSectionTitle(title: 'Horario del camión'), color: Colors.white,
_HorarioCard(), size: 18,
const SizedBox(height: 16), ),
GestureDetector( ),
onTap: () async { ),
],
),
);
}
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,64 +266,82 @@ 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: Column( child: ClipRRect(
crossAxisAlignment: CrossAxisAlignment.start, borderRadius: BorderRadius.circular(AppTheme.radiusLg - 0.5),
children: [ child: IntrinsicHeight(
Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Container( Container(width: 3, color: AppTheme.primary),
width: 44,
height: 44,
decoration: BoxDecoration(
color: AppTheme.primaryLight,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.home_outlined,
color: AppTheme.primary,
size: 24,
),
),
const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Padding(
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.all(16),
children: [ child: Column(
Text( crossAxisAlignment: CrossAxisAlignment.start,
casa.alias, children: [
style: const TextStyle( Row(
fontSize: 15, children: [
fontWeight: FontWeight.w600, Container(
color: AppTheme.textPrimary, width: 44,
height: 44,
decoration: BoxDecoration(
color: AppTheme.primaryLight,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.home_outlined,
color: AppTheme.primary,
size: 24,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
casa.alias,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 4),
AppStatusBadge.green(
casa.activa ? 'Activa' : 'Inactiva',
),
],
),
),
],
), ),
), const SizedBox(height: 14),
const SizedBox(height: 4), const Divider(color: AppTheme.borderLight),
AppStatusBadge.green(casa.activa ? 'Activa' : 'Inactiva'), const SizedBox(height: 10),
], _DetailRow(
icon: Icons.location_on_outlined,
text: casa.direccionCompleta,
),
const SizedBox(height: 8),
_DetailRow(
icon: Icons.radar_outlined,
text:
'Alerta a ${casa.radioAlertaMetros} m de distancia',
),
],
),
), ),
), ),
], ],
), ),
const SizedBox(height: 14), ),
const Divider(color: AppTheme.borderLight),
const SizedBox(height: 10),
_DetailRow(
icon: Icons.location_on_outlined,
text: casa.direccionCompleta,
),
const SizedBox(height: 8),
_DetailRow(
icon: Icons.radar_outlined,
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,169 +76,357 @@ 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( key: _formKey,
padding: const EdgeInsets.all(16), child: CustomScrollView(
child: Form( slivers: [
key: _formKey, SliverToBoxAdapter(child: _buildPageHeader(context)),
child: AppCard( SliverPadding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
sliver: SliverList(
delegate: SliverChildListDelegate([
const AppSectionTitle(title: 'Detalles del reporte'),
AppFormCard(
icon: Icons.local_shipping_outlined,
title: 'Unidad asignada a tu zona',
child: _AssignedUnitBadge(
assignedUnitAsync: assignedUnitAsync,
),
),
const SizedBox(height: 16),
AppFormCard(
icon: Icons.category_outlined,
title: 'Categoría del problema',
child: DropdownButtonFormField<String>(
initialValue: _category,
isExpanded: true,
decoration: const InputDecoration(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
items: [
for (final entry in incidentCategories.entries)
DropdownMenuItem<String>(
value: entry.key,
child: Text(entry.value),
),
],
onChanged: (v) {
if (v != null) setState(() => _category = v);
},
validator: (v) => (v == null || v.isEmpty)
? 'Selecciona una categoría'
: null,
),
),
const SizedBox(height: 16),
AppFormCard(
icon: Icons.description_outlined,
title: 'Descripción',
child: AppFormField(
label: 'Cuéntanos qué pasó',
controller: _descCtrl,
hint: 'Cuéntanos qué pasó…',
maxLines: 5,
keyboardType: TextInputType.multiline,
validator: (v) {
final t = (v ?? '').trim();
if (t.length < 3) {
return 'Describe el problema (mínimo 3 caracteres)';
}
return null;
},
),
),
const SizedBox(height: 16),
AppFormCard(
icon: Icons.photo_camera_outlined,
title: 'Evidencia fotográfica',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _submitting ? null : _pickPhoto,
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.primary,
side: const BorderSide(
color: AppTheme.primary,
),
),
icon: const Icon(Icons.photo_camera_outlined),
label: Text(
_photo == null
? 'Adjuntar foto'
: 'Cambiar foto',
),
),
),
if (_photo != null) ...[
const SizedBox(width: 8),
IconButton(
tooltip: 'Quitar foto',
icon: const Icon(
Icons.close,
color: AppTheme.danger,
),
onPressed: _submitting
? null
: () => setState(() => _photo = null),
),
],
],
),
if (_photo != null) ...[
const SizedBox(height: 12),
ClipRRect(
borderRadius:
BorderRadius.circular(AppTheme.radiusMd),
child: Image.file(
_photo!,
height: 180,
width: double.infinity,
fit: BoxFit.cover,
),
),
],
],
),
),
const SizedBox(height: 32),
]),
),
),
],
),
),
bottomNavigationBar: _buildSubmitButton(),
);
}
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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const AppSectionTitle(title: 'Detalles del reporte'),
// Unidad
Text( Text(
'Unidad relacionada (opcional)', 'Reportar un problema',
style: const TextStyle( style: TextStyle(
fontSize: 12, fontSize: 20,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w700,
color: AppTheme.textSecondary, color: Colors.white,
), ),
), ),
const SizedBox(height: 6), SizedBox(height: 2),
unitsAsync.when(
loading: () => const LinearProgressIndicator(),
error: (e, _) => Text(
'No se pudieron cargar las unidades',
style: const TextStyle(
color: AppTheme.danger,
fontSize: 12,
),
),
data: (units) => DropdownButtonFormField<int?>(
initialValue: _unitId,
isExpanded: true,
items: [
const DropdownMenuItem<int?>(
value: null,
child: Text('Sin unidad'),
),
for (final u in units)
DropdownMenuItem<int?>(
value: u.id,
child: Text(u.label),
),
],
onChanged: (v) => setState(() => _unitId = v),
),
),
const SizedBox(height: 16),
// Categoría
Text( Text(
'Categoría', 'Ayúdanos a mejorar el servicio',
style: const TextStyle( style: TextStyle(fontSize: 13, color: Colors.white70),
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 6),
DropdownButtonFormField<String>(
initialValue: _category,
isExpanded: true,
items: [
for (final entry in incidentCategories.entries)
DropdownMenuItem<String>(
value: entry.key,
child: Text(entry.value),
),
],
onChanged: (v) {
if (v != null) setState(() => _category = v);
},
validator: (v) => (v == null || v.isEmpty)
? 'Selecciona una categoría'
: null,
),
const SizedBox(height: 16),
AppFormField(
label: 'Descripción',
controller: _descCtrl,
hint: 'Cuéntanos qué pasó…',
maxLines: 5,
keyboardType: TextInputType.multiline,
validator: (v) {
final t = (v ?? '').trim();
if (t.length < 3)
return 'Describe el problema (mínimo 3 caracteres)';
return null;
},
),
const SizedBox(height: 16),
// Foto
Row(
children: [
OutlinedButton.icon(
onPressed: _submitting ? null : _pickPhoto,
icon: const Icon(Icons.photo_camera_outlined),
label: Text(
_photo == null ? 'Adjuntar foto' : 'Cambiar foto',
),
),
if (_photo != null) ...[
const SizedBox(width: 8),
IconButton(
tooltip: 'Quitar foto',
icon: const Icon(Icons.close, color: AppTheme.danger),
onPressed: _submitting
? null
: () => setState(() => _photo = null),
),
],
],
),
if (_photo != null) ...[
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
child: Image.file(
_photo!,
height: 180,
width: double.infinity,
fit: BoxFit.cover,
),
),
],
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Enviar reporte'),
),
), ),
], ],
), ),
), ),
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,
),
),
],
),
);
}
Widget _buildSubmitButton() {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: 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,62 +286,75 @@ 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: Row( child: ClipRRect(
crossAxisAlignment: CrossAxisAlignment.start, borderRadius: BorderRadius.circular(9.5),
children: [ child: IntrinsicHeight(
Container( child: Row(
width: 32, crossAxisAlignment: CrossAxisAlignment.stretch,
height: 32, children: [
decoration: BoxDecoration( Container(width: 3, color: accent),
color: accent.withOpacity(0.1), Expanded(
borderRadius: BorderRadius.circular(8), child: Padding(
), padding: const EdgeInsets.all(12),
child: Icon(_icon, size: 16, color: accent), child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(_icon, size: 16, color: accent),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
item.body,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
height: 1.4,
),
),
const SizedBox(height: 4),
Text(
_relativeTime(),
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
),
],
), ),
const SizedBox(width: 10), ),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
item.body,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
height: 1.4,
),
),
const SizedBox(height: 4),
Text(
_relativeTime(),
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
],
), ),
); );
} }

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,114 +152,225 @@ 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)),
const AppSectionTitle(title: 'Datos personales'), SliverPadding(
AppCard( padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
child: Column( sliver: SliverList(
children: [ delegate: SliverChildListDelegate([
AppFormField( const AppSectionTitle(title: 'Datos personales'),
label: 'Nombre', AppFormCard(
controller: _nameCtrl, icon: Icons.person_outline,
validator: (v) => (v == null || v.trim().isEmpty) title: 'Información personal',
? 'Ingresa tu nombre' child: Column(
: null, children: [
), AppFormField(
const SizedBox(height: 12), label: 'Nombre',
AppFormField( controller: _nameCtrl,
label: 'Correo electrónico', validator: (v) =>
controller: _emailCtrl, (v == null || v.trim().isEmpty)
keyboardType: TextInputType.emailAddress, ? 'Ingresa tu nombre'
validator: (v) { : null,
if (v == null || v.trim().isEmpty) return null; ),
if (!v.contains('@')) return 'Correo inválido'; const SizedBox(height: 12),
return null; AppFormField(
}, label: 'Correo electrónico',
), controller: _emailCtrl,
const SizedBox(height: 12), keyboardType: TextInputType.emailAddress,
_PhoneField(controller: _phoneCtrl), validator: (v) {
], if (v == null || v.trim().isEmpty) return null;
), if (!v.contains('@')) return 'Correo inválido';
), return null;
const SizedBox(height: 20), },
const AppSectionTitle(title: 'Cambiar contraseña'), ),
AppCard( const SizedBox(height: 12),
child: Column( _PhoneField(controller: _phoneCtrl),
children: [ ],
AppFormField(
label: 'Contraseña actual',
controller: _currentPasswordCtrl,
obscureText: true,
validator: (v) {
if (!_wantsPasswordChange) return null;
if (v == null || v.length < 6) {
return 'Mínimo 6 caracteres';
}
return null;
},
),
const SizedBox(height: 12),
AppFormField(
label: 'Nueva contraseña',
controller: _newPasswordCtrl,
obscureText: true,
validator: (v) {
if (!_wantsPasswordChange) return null;
if (v == null || v.length < 6) {
return 'Mínimo 6 caracteres';
}
return null;
},
),
const SizedBox(height: 12),
AppFormField(
label: 'Confirmar nueva contraseña',
controller: _confirmPasswordCtrl,
obscureText: true,
validator: (v) {
if (!_wantsPasswordChange) return null;
if (v == null || v.isEmpty) {
return 'Confirma la contraseña';
}
if (v != _newPasswordCtrl.text) return 'No coincide';
return null;
},
),
const SizedBox(height: 8),
const Align(
alignment: Alignment.centerLeft,
child: Text(
'Déjalo en blanco si no deseas cambiarla.',
style: TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
), ),
), ),
], const SizedBox(height: 20),
const AppSectionTitle(title: 'Cambiar contraseña'),
AppFormCard(
icon: Icons.lock_outline,
title: 'Seguridad',
child: Column(
children: [
AppFormField(
label: 'Contraseña actual',
controller: _currentPasswordCtrl,
obscureText: true,
validator: (v) {
if (!_wantsPasswordChange) return null;
if (v == null || v.length < 6) {
return 'Mínimo 6 caracteres';
}
return null;
},
),
const SizedBox(height: 12),
AppFormField(
label: 'Nueva contraseña',
controller: _newPasswordCtrl,
obscureText: true,
validator: (v) {
if (!_wantsPasswordChange) return null;
if (v == null || v.length < 6) {
return 'Mínimo 6 caracteres';
}
return null;
},
),
const SizedBox(height: 12),
AppFormField(
label: 'Confirmar nueva contraseña',
controller: _confirmPasswordCtrl,
obscureText: true,
validator: (v) {
if (!_wantsPasswordChange) return null;
if (v == null || v.isEmpty) {
return 'Confirma la contraseña';
}
if (v != _newPasswordCtrl.text) {
return 'No coincide';
}
return null;
},
),
const SizedBox(height: 8),
const Align(
alignment: Alignment.centerLeft,
child: Text(
'Déjalo en blanco si no deseas cambiarla.',
style: TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
),
],
),
),
const SizedBox(height: 32),
]),
), ),
), ),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Guardar cambios'),
),
),
const SizedBox(height: 24),
], ],
), ),
); );
}, },
), ),
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,
child: ElevatedButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Guardar cambios'),
),
),
),
); );
} }
} }
@@ -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,91 +19,95 @@ 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),
AppMenuTile( sliver: SliverList(
icon: Icons.person_outline, delegate: SliverChildListDelegate([
title: 'Editar perfil', const AppSectionTitle(title: 'Cuenta'),
subtitle: 'Nombre, correo, teléfono y contraseña', AppMenuTile(
onTap: () => context.push('/edit-profile'), icon: Icons.person_outline,
), title: 'Editar perfil',
if ((userAsync.asData?.value.isAdmin ?? false) || subtitle: 'Nombre, correo, teléfono y contraseña',
authRole == 'admin') onTap: () => context.push('/edit-profile'),
AppMenuTile( ),
icon: Icons.admin_panel_settings_outlined, if ((userAsync.asData?.value.isAdmin ?? false) ||
title: 'Panel de administración', authRole == 'admin')
subtitle: 'Gestiona usuarios, rutas y camiones', AppMenuTile(
onTap: () => context.go('/admin'), icon: Icons.admin_panel_settings_outlined,
), title: 'Panel de administración',
subtitle: 'Gestiona usuarios, rutas y camiones',
const SizedBox(height: 16), onTap: () => context.go('/admin'),
const AppSectionTitle(title: 'Soporte'), ),
AppMenuTile(
icon: Icons.pets, const SizedBox(height: 16),
title: 'Hablar con Eco (Asistente IA)', const AppSectionTitle(title: 'Herramientas'),
subtitle: 'Guía de separación de residuos', AppMenuTile(
onTap: () { icon: Icons.feedback_outlined,
Navigator.push( title: 'Buzón de retroalimentación',
context, subtitle: 'Califica el servicio de recolección',
MaterialPageRoute(builder: (_) => const AiPetChatScreen()), onTap: () => context.push('/feedback'),
); ),
},
), const SizedBox(height: 16),
AppMenuTile( const AppSectionTitle(title: 'Soporte'),
icon: Icons.help_outline, AppMenuTile(
title: 'Ayuda y preguntas frecuentes', icon: Icons.help_outline,
subtitle: 'Chatea con nuestro asistente', title: 'Ayuda y preguntas frecuentes',
onTap: () => context.push('/help'), subtitle: 'Chatea con nuestro asistente',
), onTap: () => context.push('/help'),
AppMenuTile( ),
icon: Icons.bug_report_outlined, AppMenuTile(
title: 'Reportar un problema', icon: Icons.bug_report_outlined,
subtitle: 'Reporta una unidad o incidente', title: 'Reportar un problema',
onTap: () => context.push('/report-issue'), subtitle: 'Reporta una unidad o incidente',
), onTap: () => context.push('/report-issue'),
AppMenuTile( ),
icon: Icons.info_outline, AppMenuTile(
title: 'Acerca de la app', icon: Icons.info_outline,
onTap: () => context.push('/about'), title: 'Acerca de la app',
), onTap: () => context.push('/about'),
),
const SizedBox(height: 16),
AppMenuTile( const SizedBox(height: 16),
icon: Icons.logout_rounded, AppMenuTile(
title: 'Cerrar sesión', icon: Icons.logout_rounded,
iconColor: AppTheme.danger, title: 'Cerrar sesión',
titleColor: AppTheme.danger, iconColor: AppTheme.danger,
trailing: const SizedBox.shrink(), titleColor: AppTheme.danger,
onTap: () => _confirmarCerrarSesion(context, ref), trailing: const SizedBox.shrink(),
), onTap: () => _confirmarCerrarSesion(context, ref),
),
const SizedBox(height: 32),
const Center( const SizedBox(height: 32),
child: Text( const Center(
'Recolecta v1.0.0\nServicio de Limpia · Celaya, Gto.', child: Text(
textAlign: TextAlign.center, 'Recolecta v1.0.0\nServicio de Limpia · Celaya, Gto.',
style: TextStyle( textAlign: TextAlign.center,
fontSize: 12, style: TextStyle(
color: AppTheme.textHint, fontSize: 12,
height: 1.6, color: AppTheme.textHint,
), height: 1.6,
),
),
),
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( Text(
child: Column( displayName,
crossAxisAlignment: CrossAxisAlignment.start, style: const TextStyle(
children: [ fontSize: 20,
Text( fontWeight: FontWeight.w700,
displayName, color: Colors.white,
style: const TextStyle( ),
fontSize: 16, ),
fontWeight: FontWeight.w700, const SizedBox(height: 4),
color: AppTheme.textPrimary, Text(
), email,
), style: TextStyle(
const SizedBox(height: 2), fontSize: 13,
Text( color: Colors.white.withValues(alpha: 0.8),
email, ),
style: const TextStyle( ),
fontSize: 13, const SizedBox(height: 10),
color: AppTheme.textSecondary, Container(
), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 5),
), decoration: BoxDecoration(
const SizedBox(height: 6), color: Colors.white.withValues(alpha: 0.18),
AppStatusBadge.green( borderRadius: BorderRadius.circular(AppTheme.radiusFull),
isAdmin border: Border.all(
? 'Administrador' color: Colors.white.withValues(alpha: 0.3),
: isDriver ),
? 'Chofer' ),
: 'Ciudadano', 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,89 +113,83 @@ class _SplashScreenState extends State<SplashScreen>
), ),
SafeArea( SafeArea(
child: Column( child: SizedBox(
children: [ width: double.infinity,
const Spacer(flex: 3), child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Spacer(flex: 3),
// Logo central // Logo central
ScaleTransition( ScaleTransition(
scale: _logoScale, scale: _logoScale,
child: FadeTransition( child: FadeTransition(
opacity: _logoOpacity, opacity: _logoOpacity,
child: Container( child: Container(
width: 118, decoration: BoxDecoration(
height: 118, shape: BoxShape.circle,
decoration: BoxDecoration( boxShadow: [
color: Colors.white.withValues(alpha: 0.15), BoxShadow(
borderRadius: BorderRadius.circular(34), color: Colors.black.withValues(alpha: 0.2),
border: Border.all( blurRadius: 24,
color: Colors.white.withValues(alpha: 0.35), offset: const Offset(0, 8),
width: 2, ),
],
), ),
boxShadow: [ child: const VideoMascot(size: 130, zoom: 6.5),
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
child: const Icon(
Icons.recycling_rounded,
size: 64,
color: Colors.white,
), ),
), ),
), ),
),
const SizedBox(height: 36), const SizedBox(height: 36),
// Nombre de la app // Nombre de la app
SlideTransition( SlideTransition(
position: _textSlide, position: _textSlide,
child: FadeTransition( child: FadeTransition(
opacity: _textOpacity, opacity: _textOpacity,
child: const Text( child: const Text(
'RecolectApp', 'RecolectApp',
style: TextStyle(
fontSize: 38,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: -1.0,
height: 1.1,
),
),
),
),
const SizedBox(height: 10),
// Subtítulo
FadeTransition(
opacity: _subtitleOpacity,
child: Text(
'Sistema de Recolección Inteligente',
style: TextStyle( style: TextStyle(
fontSize: 38, fontSize: 14,
fontWeight: FontWeight.w800, color: Colors.white.withValues(alpha: 0.8),
color: Colors.white, letterSpacing: 0.4,
letterSpacing: -1.0, fontWeight: FontWeight.w400,
height: 1.1,
), ),
), ),
), ),
),
const SizedBox(height: 10), const Spacer(flex: 3),
// Subtítulo // Indicador de carga
FadeTransition( FadeTransition(
opacity: _subtitleOpacity, opacity: _subtitleOpacity,
child: Text( child: const Padding(
'Sistema de Recolección Inteligente', padding: EdgeInsets.only(bottom: 52),
style: TextStyle( child: _DotsLoader(),
fontSize: 14,
color: Colors.white.withValues(alpha: 0.8),
letterSpacing: 0.4,
fontWeight: FontWeight.w400,
), ),
), ),
), ],
),
const Spacer(flex: 3),
// Indicador de carga
FadeTransition(
opacity: _subtitleOpacity,
child: const Padding(
padding: EdgeInsets.only(bottom: 52),
child: _DotsLoader(),
),
),
],
), ),
), ),
], ],

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