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-glass-effect": "~56.0.4",
|
||||||
"expo-image": "~56.0.8",
|
"expo-image": "~56.0.8",
|
||||||
"expo-linking": "~56.0.11",
|
"expo-linking": "~56.0.11",
|
||||||
|
"expo-notifications": "~56.0.12",
|
||||||
"expo-router": "~56.2.5",
|
"expo-router": "~56.2.5",
|
||||||
"expo-splash-screen": "~56.0.9",
|
"expo-splash-screen": "~56.0.9",
|
||||||
"expo-status-bar": "~56.0.4",
|
"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": {
|
"node_modules/balanced-match": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
"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": {
|
"node_modules/expo-asset": {
|
||||||
"version": "56.0.13",
|
"version": "56.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-56.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-56.0.13.tgz",
|
||||||
@@ -4000,6 +4016,24 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"node_modules/expo-router": {
|
||||||
"version": "56.2.5",
|
"version": "56.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/expo-router/-/expo-router-56.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/expo-router/-/expo-router-56.2.5.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"expo-glass-effect": "~56.0.4",
|
"expo-glass-effect": "~56.0.4",
|
||||||
"expo-image": "~56.0.8",
|
"expo-image": "~56.0.8",
|
||||||
"expo-linking": "~56.0.11",
|
"expo-linking": "~56.0.11",
|
||||||
|
"expo-notifications": "~56.0.12",
|
||||||
"expo-router": "~56.2.5",
|
"expo-router": "~56.2.5",
|
||||||
"expo-splash-screen": "~56.0.9",
|
"expo-splash-screen": "~56.0.9",
|
||||||
"expo-status-bar": "~56.0.4",
|
"expo-status-bar": "~56.0.4",
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
|
import { View } from "react-native";
|
||||||
import { Tabs } from "expo-router";
|
import { Tabs } from "expo-router";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { AppProvider } from "../context/AppContext";
|
import { AppProvider } from "../context/AppContext";
|
||||||
|
import NotificationToast from "../components/NotificationToast";
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
return (
|
return (
|
||||||
<AppProvider>
|
<AppProvider>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<NotificationToast />
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
@@ -131,6 +135,7 @@ export default function Layout() {
|
|||||||
options={{ href: null }}
|
options={{ href: null }}
|
||||||
/>
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
</View>
|
||||||
</AppProvider>
|
</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 InboxNotification,
|
||||||
type UserStatusResponse,
|
type UserStatusResponse,
|
||||||
} from "../services/tracking.service";
|
} 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
|
const POLL_INTERVAL_MS = 30_000; // 30s, igual que el simulador del backend
|
||||||
|
|
||||||
@@ -29,6 +33,8 @@ interface AppContextValue {
|
|||||||
notifications: InboxNotification[];
|
notifications: InboxNotification[];
|
||||||
route: UserStatusResponse["route"] | null;
|
route: UserStatusResponse["route"] | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
toast: InboxNotification | null;
|
||||||
|
dismissToast: () => void;
|
||||||
login: (email: string, password: string) => Promise<void>;
|
login: (email: string, password: string) => Promise<void>;
|
||||||
register: (name: string, email: string, password: string) => Promise<void>;
|
register: (name: string, email: string, password: string) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
@@ -51,6 +57,15 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
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 () => {
|
const refreshStatus = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -59,6 +74,21 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
setEta(res.eta);
|
setEta(res.eta);
|
||||||
setRoute(res.route);
|
setRoute(res.route);
|
||||||
setNotifications(res.notifications);
|
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) {
|
} catch (err) {
|
||||||
console.error("Status refresh failed:", err);
|
console.error("Status refresh failed:", err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -98,6 +128,11 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
setEta(null);
|
setEta(null);
|
||||||
setRoute(null);
|
setRoute(null);
|
||||||
setNotifications([]);
|
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.
|
// Polling controlado: arranca al loguearse, se detiene al deslogearse.
|
||||||
@@ -131,6 +166,8 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
notifications,
|
notifications,
|
||||||
route,
|
route,
|
||||||
loading,
|
loading,
|
||||||
|
toast,
|
||||||
|
dismissToast,
|
||||||
login,
|
login,
|
||||||
register,
|
register,
|
||||||
logout,
|
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