feat: add notification push

This commit is contained in:
Diego Mireles
2026-05-23 07:34:21 -06:00
parent 7de53482b1
commit b6addb411a
6 changed files with 262 additions and 0 deletions

View File

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

View File

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

View File

@@ -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 (
<AppProvider>
<View style={{ flex: 1 }}>
<NotificationToast />
<Tabs
screenOptions={{
headerShown: false,
@@ -131,6 +135,7 @@ export default function Layout() {
options={{ href: null }}
/>
</Tabs>
</View>
</AppProvider>
);
}

View File

@@ -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 (
<Animated.View
style={[
styles.container,
{ transform: [{ translateY }] },
]}
pointerEvents="box-none"
>
<Pressable style={styles.card} onPress={dismissToast}>
<View style={styles.iconWrap}>
<Ionicons name="notifications" size={22} color="#FFFFFF" />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.title} numberOfLines={1}>
{toast.title}
</Text>
<Text style={styles.body} numberOfLines={2}>
{toast.body}
</Text>
</View>
<Ionicons name="close" size={18} color="#FFFFFF" />
</Pressable>
</Animated.View>
);
}
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,
},
});

View File

@@ -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<void>;
register: (name: string, email: string, password: string) => Promise<void>;
logout: () => void;
@@ -51,6 +57,15 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
const [loading, setLoading] = useState(false);
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
// IDs de notificaciones que ya disparamos como push local — evita duplicados
const shownNotificationIds = useRef<Set<string>>(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<InboxNotification | null>(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,

View File

@@ -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<void> {
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<boolean> {
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<string, unknown> = {},
): Promise<void> {
await Notifications.scheduleNotificationAsync({
content: {
title,
body,
data,
sound: "default",
},
trigger: null, // inmediata
});
}