chore: fix notif

This commit is contained in:
2026-03-02 20:46:07 +01:00
parent 0085a39537
commit d299601667
10 changed files with 348 additions and 576 deletions
+1 -12
View File
@@ -27,9 +27,7 @@
"android.permission.ACCESS_BACKGROUND_LOCATION",
"android.permission.ACCESS_COARSE_LOCATION",
"android.permission.ACCESS_FINE_LOCATION",
"android.permission.RECEIVE_BOOT_COMPLETED",
"android.permission.VIBRATE",
"android.permission.POST_NOTIFICATIONS"
"android.permission.VIBRATE"
]
},
"web": {
@@ -38,15 +36,6 @@
"plugins": [
"expo-font",
"expo-location",
[
"expo-notifications",
{
"icon": "./assets/icon.png",
"color": "#000000",
"defaultChannel": "deliveries",
"sounds": []
}
],
[
"expo-build-properties",
{
@@ -1,11 +1,23 @@
import React from "react";
import { TouchableOpacity, View } from "react-native";
import React, { useState, useEffect, useRef, useCallback } from "react";
import {
TouchableOpacity,
View,
Text,
Modal,
ScrollView,
StyleSheet,
} from "react-native";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { Ionicons } from "@expo/vector-icons";
import { useAuth } from "../auth/AuthContext";
import { useTheme } from "../context/ThemeContext";
import { logoutAdmin } from "../api/api_admin";
import { fontSize, spacing } from "../theme";
import {
getLivreurNotifications,
markLivreurNotificationsRead,
} from "../api/api_delivery";
import type { LivreurNotification } from "../api/api_delivery";
import { fontSize, spacing, borderRadius } from "../theme";
import type { DeliveryTabParamList } from "./types";
import DashboardScreen from "../screens/delivery/DashboardScreen";
@@ -14,99 +26,341 @@ import AlertsScreen from "../screens/delivery/AlertsScreen";
const Tab = createBottomTabNavigator<DeliveryTabParamList>();
function formatNotifTime(dateStr: string): string {
try {
const diffMs = Date.now() - new Date(dateStr).getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return "À l'instant";
if (diffMin < 60) return `Il y a ${diffMin} min`;
const diffH = Math.floor(diffMin / 60);
if (diffH < 24) return `Il y a ${diffH}h`;
return `Il y a ${Math.floor(diffH / 24)} jours`;
} catch {
return "";
}
}
export default function DeliveryNavigator() {
const { logout } = useAuth();
const { colors, isDark, toggleTheme } = useTheme();
const [notifications, setNotifications] = useState<LivreurNotification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [showModal, setShowModal] = useState(false);
const seenIdsRef = useRef<Set<string>>(new Set());
const isFirstLoad = useRef(true);
const fetchNotifications = useCallback(async () => {
const res = await getLivreurNotifications();
if (!res.success || !res.notifications) return;
setNotifications(res.notifications);
if (!isFirstLoad.current) {
let newUnread = 0;
for (const n of res.notifications) {
if (!n.read) {
const key = `${n.command_id}-${n.type}-${n.created_at}`;
if (!seenIdsRef.current.has(key)) {
seenIdsRef.current.add(key);
newUnread++;
}
}
}
if (newUnread > 0) {
setUnreadCount((prev) => prev + newUnread);
}
} else {
for (const n of res.notifications) {
const key = `${n.command_id}-${n.type}-${n.created_at}`;
seenIdsRef.current.add(key);
}
setUnreadCount(res.unread_count ?? 0);
isFirstLoad.current = false;
}
}, []);
useEffect(() => {
fetchNotifications();
const interval = setInterval(fetchNotifications, 15000);
return () => clearInterval(interval);
}, [fetchNotifications]);
const openModal = async () => {
setShowModal(true);
if (unreadCount > 0) {
await markLivreurNotificationsRead();
setUnreadCount(0);
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
}
};
const handleLogout = async () => {
await logoutAdmin();
await logout();
};
return (
<Tab.Navigator
screenOptions={{
headerStyle: { backgroundColor: colors.bgSecondary },
headerTintColor: colors.textWhite,
headerRight: () => (
<View
style={{
flexDirection: "row",
alignItems: "center",
marginRight: spacing.l,
}}
>
<TouchableOpacity
onPress={toggleTheme}
style={{ marginRight: spacing.m }}
<>
<Tab.Navigator
screenOptions={{
headerStyle: { backgroundColor: colors.bgSecondary },
headerTintColor: colors.textWhite,
headerRight: () => (
<View
style={{
flexDirection: "row",
alignItems: "center",
marginRight: spacing.l,
gap: spacing.m,
}}
>
{/* Cloche notifications */}
<TouchableOpacity
onPress={openModal}
style={{ position: "relative" }}
>
<Ionicons
name="notifications-outline"
size={24}
color={colors.textSecondary}
/>
{unreadCount > 0 && (
<View style={styles.badge}>
<Text style={styles.badgeText}>
{unreadCount > 9 ? "9+" : unreadCount}
</Text>
</View>
)}
</TouchableOpacity>
<TouchableOpacity onPress={toggleTheme}>
<Ionicons
name={isDark ? "sunny-outline" : "moon-outline"}
size={22}
color={colors.textSecondary}
/>
</TouchableOpacity>
<TouchableOpacity onPress={handleLogout}>
<Ionicons
name="log-out-outline"
size={24}
color={colors.textSecondary}
/>
</TouchableOpacity>
</View>
),
tabBarStyle: {
backgroundColor: colors.bgSecondary,
borderTopColor: colors.border,
borderTopWidth: 1,
},
tabBarActiveTintColor: colors.success,
tabBarInactiveTintColor: colors.textMuted,
tabBarLabelStyle: { fontSize: fontSize.xs },
}}
>
<Tab.Screen
name="Dashboard"
component={DashboardScreen}
options={{
title: "Livraisons",
tabBarIcon: ({ color, size }) => (
<Ionicons
name={isDark ? "sunny-outline" : "moon-outline"}
size={22}
color={colors.textSecondary}
name="navigate-outline"
size={size}
color={color}
/>
</TouchableOpacity>
<TouchableOpacity onPress={handleLogout}>
),
}}
/>
<Tab.Screen
name="Stats"
component={StatsScreen}
options={{
title: "Stats",
tabBarIcon: ({ color, size }) => (
<Ionicons
name="log-out-outline"
size={24}
color={colors.textSecondary}
name="stats-chart-outline"
size={size}
color={color}
/>
</TouchableOpacity>
),
}}
/>
<Tab.Screen
name="Alerts"
component={AlertsScreen}
options={{
title: "Alertes",
tabBarIcon: ({ color, size }) => (
<Ionicons
name="alert-circle-outline"
size={size}
color={color}
/>
),
}}
/>
</Tab.Navigator>
{/* Modal notifications */}
<Modal
visible={showModal}
animationType="slide"
transparent
onRequestClose={() => setShowModal(false)}
>
<View style={styles.overlay}>
<View
style={[
styles.modalContent,
{ backgroundColor: colors.bgPrimary },
]}
>
{/* Header modal */}
<View
style={[
styles.modalHeader,
{ borderBottomColor: colors.border },
]}
>
<View style={styles.modalTitleRow}>
<Ionicons
name="notifications-outline"
size={20}
color={colors.success}
/>
<Text
style={[
styles.modalTitle,
{ color: colors.textPrimary },
]}
>
Notifications
</Text>
</View>
<TouchableOpacity onPress={() => setShowModal(false)}>
<Ionicons
name="close"
size={24}
color={colors.textSecondary}
/>
</TouchableOpacity>
</View>
{/* Liste */}
<ScrollView style={{ flex: 1 }}>
{notifications.length === 0 ? (
<Text
style={[
styles.emptyText,
{ color: colors.textMuted },
]}
>
Aucune notification
</Text>
) : (
notifications.map((n, i) => (
<View
key={`${n.command_id}-${n.type}-${n.created_at}-${i}`}
style={[
styles.notifItem,
{
borderBottomColor: colors.border,
borderLeftColor: n.read
? "transparent"
: colors.success,
backgroundColor: n.read
? "transparent"
: colors.success + "10",
},
]}
>
<Text
style={[
styles.notifMessage,
{ color: colors.textPrimary },
]}
>
{n.message}
</Text>
<Text
style={[
styles.notifTime,
{ color: colors.textMuted },
]}
>
{formatNotifTime(n.created_at)}
</Text>
</View>
))
)}
</ScrollView>
</View>
),
tabBarStyle: {
backgroundColor: colors.bgSecondary,
borderTopColor: colors.border,
borderTopWidth: 1,
},
tabBarActiveTintColor: colors.success,
tabBarInactiveTintColor: colors.textMuted,
tabBarLabelStyle: { fontSize: fontSize.xs },
}}
>
<Tab.Screen
name="Dashboard"
component={DashboardScreen}
options={{
title: "Livraisons",
tabBarIcon: ({ color, size }) => (
<Ionicons
name="navigate-outline"
size={size}
color={color}
/>
),
}}
/>
<Tab.Screen
name="Stats"
component={StatsScreen}
options={{
title: "Stats",
tabBarIcon: ({ color, size }) => (
<Ionicons
name="stats-chart-outline"
size={size}
color={color}
/>
),
}}
/>
<Tab.Screen
name="Alerts"
component={AlertsScreen}
options={{
title: "Alertes",
tabBarIcon: ({ color, size }) => (
<Ionicons
name="alert-circle-outline"
size={size}
color={color}
/>
),
}}
/>
</Tab.Navigator>
</View>
</Modal>
</>
);
}
const styles = StyleSheet.create({
badge: {
position: "absolute",
top: -4,
right: -4,
backgroundColor: "#ef4444",
borderRadius: 10,
minWidth: 18,
height: 18,
justifyContent: "center",
alignItems: "center",
},
badgeText: {
color: "#fff",
fontSize: 10,
fontWeight: "bold",
},
overlay: {
flex: 1,
justifyContent: "flex-end",
backgroundColor: "rgba(0,0,0,0.5)",
},
modalContent: {
height: "70%",
borderTopLeftRadius: borderRadius.lg,
borderTopRightRadius: borderRadius.lg,
},
modalHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: spacing.l,
borderBottomWidth: 1,
},
modalTitleRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.s,
},
modalTitle: {
fontSize: fontSize.lg,
fontWeight: "bold",
},
emptyText: {
textAlign: "center",
marginTop: spacing.xl,
fontSize: fontSize.sm,
},
notifItem: {
padding: spacing.m,
borderBottomWidth: 1,
borderLeftWidth: 3,
},
notifMessage: {
fontSize: fontSize.sm,
marginBottom: spacing.xs,
},
notifTime: {
fontSize: fontSize.xs,
},
});
@@ -49,13 +49,6 @@ import TomTomMap, {
TomTomMarker,
} from "../../components/TomTomMap";
import DetailsModal from "../../components/ui/Modal";
import {
setupDeliveryNotificationChannel,
registerForPushNotificationsAsync,
sendPushTokenToBackend,
addNotificationReceivedListener,
} from "../../services/pushNotifications";
import { getLivreurNotifications } from "../../api/api_delivery";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const MAP_HEIGHT = 260;
@@ -110,10 +103,7 @@ export default function DashboardScreen() {
const pendingRouteAddress = useRef<string | null>(null);
const { alert, showError, showSuccess, hideAlert } = useAlert();
// Notifications
const [unreadNotifCount, setUnreadNotifCount] = useState(0);
const [detailsDelivery, setDetailsDelivery] = useState<EnrichedDelivery | null>(null);
const pushTokenRef = useRef<string | null>(null);
const STATUS_COLORS: Record<string, string> = useMemo(
() => ({
@@ -281,37 +271,6 @@ export default function DashboardScreen() {
loadData();
}, [loadData]);
// Push notifications: enregistrement + badge
useEffect(() => {
let notifSub: ReturnType<typeof addNotificationReceivedListener> | null = null;
const setupPush = async () => {
await setupDeliveryNotificationChannel();
const token = await registerForPushNotificationsAsync();
if (token) {
pushTokenRef.current = token;
await sendPushTokenToBackend(token);
}
// Charger le compteur de notifications non lues
const res = await getLivreurNotifications();
if (res.success) {
setUnreadNotifCount(res.unread_count ?? 0);
}
};
setupPush();
// Écouter les nouvelles notifications en foreground
notifSub = addNotificationReceivedListener((_notif) => {
setUnreadNotifCount((prev) => prev + 1);
loadData();
});
return () => {
if (notifSub) notifSub.remove();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Quand GPS devient disponible, rejouer la route en attente
useEffect(() => {
@@ -1401,25 +1360,6 @@ export default function DashboardScreen() {
fontWeight: "700",
},
notifBadgeBar: {
flexDirection: "row",
alignItems: "center",
gap: 8,
backgroundColor: "#FF6B0015",
borderLeftWidth: 3,
borderLeftColor: "#FF6B00",
paddingHorizontal: spacing.m,
paddingVertical: 8,
marginHorizontal: spacing.l,
marginTop: spacing.s,
borderRadius: 6,
},
notifBadgeText: {
color: "#FF6B00",
fontSize: fontSize.sm,
fontWeight: "600",
flex: 1,
},
fullscreenStatusBadge: {
flexDirection: "row",
alignItems: "center",
@@ -1666,16 +1606,6 @@ export default function DashboardScreen() {
</ScrollView>
</DetailsModal>
{/* ── Badge notifications ── */}
{unreadNotifCount > 0 && (
<View style={styles.notifBadgeBar}>
<Ionicons name="notifications" size={16} color="#FF6B00" />
<Text style={styles.notifBadgeText}>
{unreadNotifCount} nouvelle{unreadNotifCount > 1 ? "s" : ""} commande{unreadNotifCount > 1 ? "s" : ""} assignée{unreadNotifCount > 1 ? "s" : ""}
</Text>
</View>
)}
{/* ── Barre de statut ── */}
<View style={styles.statusBar}>
<Text style={styles.statusLabel}>Mon statut:</Text>
@@ -1,124 +0,0 @@
import * as Notifications from "expo-notifications";
import * as Device from "expo-device";
import Constants from "expo-constants";
import { Platform } from "react-native";
import apiClient from "../api/client";
const LIVREUR_API = "https://uber-stup.club/api/v1/livreur";
// Configuration du comportement des notifications en foreground
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldPlaySound: true,
shouldSetBadge: true,
shouldShowBanner: true,
shouldShowList: true,
}),
});
export async function setupDeliveryNotificationChannel(): Promise<void> {
if (Platform.OS === "android") {
await Notifications.setNotificationChannelAsync("deliveries", {
name: "Livraisons",
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
lightColor: "#FF6B00",
sound: "default",
enableVibrate: true,
showBadge: true,
});
}
}
// Enregistrer le device pour les push notifications et retourner le token
export async function registerForPushNotificationsAsync(): Promise<
string | null
> {
if (!Device.isDevice) {
console.log(
"⚠️ Push notifications ne fonctionnent pas sur un émulateur",
);
return null;
}
const { status: existingStatus } =
await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== "granted") {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== "granted") {
console.log("❌ Permission push notifications refusée");
return null;
}
const projectId =
Constants.expoConfig?.extra?.eas?.projectId ??
Constants.easConfig?.projectId;
if (!projectId) {
console.log("⚠️ projectId non trouvé - push notifications désactivées");
return null;
}
try {
const tokenData = await Notifications.getExpoPushTokenAsync({
projectId,
});
const pushToken = tokenData.data;
console.log("📱 [LIVREUR] Push token:", pushToken);
return pushToken;
} catch (error) {
console.error("❌ [LIVREUR] Erreur récupération push token:", error);
return null;
}
}
// Envoyer le push token au backend (endpoint livreur)
export async function sendPushTokenToBackend(
pushToken: string,
): Promise<boolean> {
try {
const { data } = await apiClient.post(`${LIVREUR_API}/push-token`, {
push_token: pushToken,
});
console.log("✅ [LIVREUR] Push token enregistré sur le backend");
return data.success === true;
} catch (error) {
console.error("❌ [LIVREUR] Erreur envoi push token:", error);
return false;
}
}
// Supprimer le push token du backend (au logout)
export async function removePushTokenFromBackend(): Promise<void> {
try {
await apiClient.delete(`${LIVREUR_API}/push-token`);
console.log("✅ [LIVREUR] Push token supprimé du backend");
} catch (error) {
console.error("❌ [LIVREUR] Erreur suppression push token:", error);
}
}
export function addNotificationReceivedListener(
callback: (notification: Notifications.Notification) => void,
): Notifications.EventSubscription {
return Notifications.addNotificationReceivedListener(callback);
}
export function addNotificationResponseListener(
callback: (response: Notifications.NotificationResponse) => void,
): Notifications.EventSubscription {
return Notifications.addNotificationResponseReceivedListener(callback);
}
export async function setBadgeCount(count: number): Promise<void> {
try {
await Notifications.setBadgeCountAsync(count);
} catch {
// Silencieux
}
}