feat: add notification push
This commit is contained in:
34
frontend/package-lock.json
generated
34
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
121
frontend/src/components/NotificationToast.tsx
Normal file
121
frontend/src/components/NotificationToast.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
64
frontend/src/lib/notifications.ts
Normal file
64
frontend/src/lib/notifications.ts
Normal 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
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user