From b6addb411a95bea1eee91793d27fd23c5f903b96 Mon Sep 17 00:00:00 2001 From: Diego Mireles Date: Sat, 23 May 2026 07:34:21 -0600 Subject: [PATCH] feat: add notification push --- frontend/package-lock.json | 34 +++++ frontend/package.json | 1 + frontend/src/app/_layout.tsx | 5 + frontend/src/components/NotificationToast.tsx | 121 ++++++++++++++++++ frontend/src/context/AppContext.tsx | 37 ++++++ frontend/src/lib/notifications.ts | 64 +++++++++ 6 files changed, 262 insertions(+) create mode 100644 frontend/src/components/NotificationToast.tsx create mode 100644 frontend/src/lib/notifications.ts 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 + }); +}