diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index b542f6e..d18a790 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -17,6 +17,7 @@
"expo-glass-effect": "~56.0.4",
"expo-image": "~56.0.8",
"expo-linking": "~56.0.11",
+ "expo-notifications": "~56.0.12",
"expo-router": "~56.2.5",
"expo-splash-screen": "~56.0.9",
"expo-status-bar": "~56.0.4",
@@ -3094,6 +3095,12 @@
}
}
},
+ "node_modules/badgin": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz",
+ "integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==",
+ "license": "MIT"
+ },
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@@ -3836,6 +3843,15 @@
}
}
},
+ "node_modules/expo-application": {
+ "version": "56.0.3",
+ "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-56.0.3.tgz",
+ "integrity": "sha512-DdGGPlMuM6cSTeKhbvh6OeLr2O/+EI5BHKYrD+Do8sJPYgLwzGrgESELfyjJCpEhFzT+TgKIdmLmWXhNUQnHiw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
"node_modules/expo-asset": {
"version": "56.0.13",
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-56.0.13.tgz",
@@ -4000,6 +4016,24 @@
"react-native": "*"
}
},
+ "node_modules/expo-notifications": {
+ "version": "56.0.12",
+ "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-56.0.12.tgz",
+ "integrity": "sha512-ZGFeA6vs1dt+9IcFtriIf2sEgBSEXGZ6OnWIYzUkdYqKpJFv1/zigUyquAMEvGbAAjGC0Uwf8qXNYJc1pyxFfA==",
+ "license": "MIT",
+ "dependencies": {
+ "@expo/image-utils": "^0.10.0",
+ "abort-controller": "^3.0.0",
+ "badgin": "^1.1.5",
+ "expo-application": "~56.0.3",
+ "expo-constants": "~56.0.14"
+ },
+ "peerDependencies": {
+ "expo": "*",
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/expo-router": {
"version": "56.2.5",
"resolved": "https://registry.npmjs.org/expo-router/-/expo-router-56.2.5.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 9f88bec..f815c80 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -12,6 +12,7 @@
"expo-glass-effect": "~56.0.4",
"expo-image": "~56.0.8",
"expo-linking": "~56.0.11",
+ "expo-notifications": "~56.0.12",
"expo-router": "~56.2.5",
"expo-splash-screen": "~56.0.9",
"expo-status-bar": "~56.0.4",
diff --git a/frontend/src/app/_layout.tsx b/frontend/src/app/_layout.tsx
index 4920f2c..394c415 100644
--- a/frontend/src/app/_layout.tsx
+++ b/frontend/src/app/_layout.tsx
@@ -1,10 +1,14 @@
+import { View } from "react-native";
import { Tabs } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { AppProvider } from "../context/AppContext";
+import NotificationToast from "../components/NotificationToast";
export default function Layout() {
return (
+
+
+
);
}
diff --git a/frontend/src/components/NotificationToast.tsx b/frontend/src/components/NotificationToast.tsx
new file mode 100644
index 0000000..0933666
--- /dev/null
+++ b/frontend/src/components/NotificationToast.tsx
@@ -0,0 +1,121 @@
+/**
+ * NotificationToast.tsx
+ *
+ * Banner animado que cae desde arriba cuando llega una notificación
+ * nueva del backend. Se autodescarta a los 5 segundos.
+ *
+ * Limitación: solo se muestra cuando la app está en primer plano. Para
+ * notificaciones del SO con la app cerrada se necesita un Development
+ * Build + expo-notifications (Expo Go SDK 53+ no lo soporta).
+ */
+
+import { useEffect, useRef } from "react";
+import {
+ Animated,
+ Pressable,
+ StyleSheet,
+ Text,
+ View,
+ Platform,
+} from "react-native";
+import { Ionicons } from "@expo/vector-icons";
+
+import { useApp } from "../context/AppContext";
+
+const AUTO_DISMISS_MS = 5000;
+
+export default function NotificationToast() {
+ const { toast, dismissToast } = useApp();
+ const translateY = useRef(new Animated.Value(-180)).current;
+
+ useEffect(() => {
+ if (!toast) return;
+
+ // Slide in
+ Animated.spring(translateY, {
+ toValue: 0,
+ useNativeDriver: true,
+ damping: 18,
+ }).start();
+
+ // Auto dismiss después de N segundos
+ const timer = setTimeout(() => {
+ Animated.timing(translateY, {
+ toValue: -180,
+ duration: 250,
+ useNativeDriver: true,
+ }).start(() => dismissToast());
+ }, AUTO_DISMISS_MS);
+
+ return () => clearTimeout(timer);
+ }, [toast, translateY, dismissToast]);
+
+ if (!toast) return null;
+
+ return (
+
+
+
+
+
+
+
+ {toast.title}
+
+
+ {toast.body}
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ position: "absolute",
+ top: Platform.OS === "ios" ? 50 : 30,
+ left: 16,
+ right: 16,
+ zIndex: 9999,
+ },
+ card: {
+ flexDirection: "row",
+ alignItems: "center",
+ backgroundColor: "#0E8A61",
+ borderRadius: 14,
+ padding: 14,
+ shadowColor: "#000",
+ shadowOffset: { width: 0, height: 6 },
+ shadowOpacity: 0.2,
+ shadowRadius: 10,
+ elevation: 10,
+ },
+ iconWrap: {
+ width: 38,
+ height: 38,
+ borderRadius: 19,
+ backgroundColor: "rgba(255,255,255,0.2)",
+ justifyContent: "center",
+ alignItems: "center",
+ marginRight: 12,
+ },
+ title: {
+ fontSize: 14,
+ fontWeight: "800",
+ color: "#FFFFFF",
+ marginBottom: 2,
+ },
+ body: {
+ fontSize: 12,
+ color: "rgba(255,255,255,0.95)",
+ lineHeight: 16,
+ },
+});
diff --git a/frontend/src/context/AppContext.tsx b/frontend/src/context/AppContext.tsx
index ed9a552..ac226b7 100644
--- a/frontend/src/context/AppContext.tsx
+++ b/frontend/src/context/AppContext.tsx
@@ -20,6 +20,10 @@ import {
type InboxNotification,
type UserStatusResponse,
} from "../services/tracking.service";
+// expo-notifications NO se importa: en Expo Go SDK 53+ se rompe al cargar
+// porque intenta auto-registrar un push token. Para push real necesitamos
+// un Development Build. Por ahora usamos un Toast in-app vía el callback
+// onNewNotification.
const POLL_INTERVAL_MS = 30_000; // 30s, igual que el simulador del backend
@@ -29,6 +33,8 @@ interface AppContextValue {
notifications: InboxNotification[];
route: UserStatusResponse["route"] | null;
loading: boolean;
+ toast: InboxNotification | null;
+ dismissToast: () => void;
login: (email: string, password: string) => Promise;
register: (name: string, email: string, password: string) => Promise;
logout: () => void;
@@ -51,6 +57,15 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
const [loading, setLoading] = useState(false);
const pollingRef = useRef | null>(null);
+ // IDs de notificaciones que ya disparamos como push local — evita duplicados
+ const shownNotificationIds = useRef>(new Set());
+ // Bandera: en el primer refresh tras login no disparamos el historial
+ // entero como notificaciones; solo lo marcamos como "ya visto".
+ const initialRefreshDoneRef = useRef(false);
+
+ // Última notificación nueva — la consume el Toast in-app
+ const [toast, setToast] = useState(null);
+ const dismissToast = useCallback(() => setToast(null), []);
const refreshStatus = useCallback(async () => {
setLoading(true);
@@ -59,6 +74,21 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
setEta(res.eta);
setRoute(res.route);
setNotifications(res.notifications);
+
+ // Detecta notificaciones nuevas (no vistas antes) y muestra la más
+ // reciente como Toast in-app. En el primer refresh no toasteamos
+ // para no inundar al usuario con el historial.
+ const newOnes: InboxNotification[] = [];
+ for (const n of res.notifications) {
+ if (!shownNotificationIds.current.has(n.id)) {
+ shownNotificationIds.current.add(n.id);
+ newOnes.push(n);
+ }
+ }
+ if (initialRefreshDoneRef.current && newOnes.length > 0) {
+ setToast(newOnes[0]); // la primera del array es la más reciente
+ }
+ initialRefreshDoneRef.current = true;
} catch (err) {
console.error("Status refresh failed:", err);
} finally {
@@ -98,6 +128,11 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
setEta(null);
setRoute(null);
setNotifications([]);
+ setToast(null);
+ // Reset del tracking de notificaciones para que la próxima sesión
+ // arranque limpia (sin disparar el historial viejo).
+ shownNotificationIds.current.clear();
+ initialRefreshDoneRef.current = false;
}, []);
// Polling controlado: arranca al loguearse, se detiene al deslogearse.
@@ -131,6 +166,8 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
notifications,
route,
loading,
+ toast,
+ dismissToast,
login,
register,
logout,
diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts
new file mode 100644
index 0000000..11f3c80
--- /dev/null
+++ b/frontend/src/lib/notifications.ts
@@ -0,0 +1,64 @@
+/**
+ * notifications.ts
+ *
+ * Wrapper sobre expo-notifications. Maneja:
+ * - Configuración del canal Android
+ * - Solicitud de permisos
+ * - Disparo de notificaciones locales (aparecen en el centro de
+ * notificaciones del sistema incluso si la app está en segundo plano)
+ *
+ * NOTA: Esto usa notificaciones LOCALES, no push remoto. Funciona cuando
+ * la app está en foreground o background (no totalmente cerrada). Para
+ * notificaciones con la app completamente cerrada hace falta un Development
+ * Build + Expo Push Service, fuera del alcance del MVP.
+ */
+
+import * as Notifications from "expo-notifications";
+import { Platform } from "react-native";
+
+// Cómo se comportan las notificaciones cuando la app está en primer plano
+Notifications.setNotificationHandler({
+ handleNotification: async () => ({
+ shouldShowBanner: true,
+ shouldShowList: true,
+ shouldPlaySound: true,
+ shouldSetBadge: false,
+ }),
+});
+
+/** Crea el canal de Android (requerido en Android 8+). Idempotente. */
+export async function setupAndroidChannel(): Promise {
+ if (Platform.OS !== "android") return;
+ await Notifications.setNotificationChannelAsync("ecoruta", {
+ name: "EcoRuta",
+ importance: Notifications.AndroidImportance.HIGH,
+ vibrationPattern: [0, 250, 250, 250],
+ lightColor: "#0E8A61",
+ sound: "default",
+ });
+}
+
+/** Solicita permiso al usuario (no-op si ya está concedido). */
+export async function requestPushPermissions(): Promise {
+ const settings = await Notifications.getPermissionsAsync();
+ if (settings.granted) return true;
+ const result = await Notifications.requestPermissionsAsync();
+ return result.granted;
+}
+
+/** Dispara una notificación local inmediata. */
+export async function fireLocalNotification(
+ title: string,
+ body: string,
+ data: Record = {},
+): Promise {
+ await Notifications.scheduleNotificationAsync({
+ content: {
+ title,
+ body,
+ data,
+ sound: "default",
+ },
+ trigger: null, // inmediata
+ });
+}