diff --git a/app/(features)/(news)/specific.tsx b/app/(features)/(news)/specific.tsx
index 662113043..8aff83d67 100644
--- a/app/(features)/(news)/specific.tsx
+++ b/app/(features)/(news)/specific.tsx
@@ -16,11 +16,11 @@ import HTMLView from 'react-native-htmlview';
import * as WebBrowser from 'expo-web-browser';
import { useTheme } from "@react-navigation/native";
import { NativeHeaderPressable, NativeHeaderSide } from "@/ui/components/NativeHeader";
-import { MenuView } from "@react-native-menu/menu";
import Icon from "@/ui/components/Icon";
import { t } from "i18next";
import List from "@/ui/components/List";
import Item from "@/ui/components/Item";
+import ActionMenu from "@/ui/components/ActionMenu";
export default function NewsPage() {
const search = useLocalSearchParams();
@@ -180,7 +180,7 @@ export default function NewsPage() {
- ({
id: theme.value,
@@ -201,7 +201,7 @@ export default function NewsPage() {
-
+
>
)
diff --git a/app/(features)/attendance.tsx b/app/(features)/attendance.tsx
index 406ea8472..729ef1ccf 100644
--- a/app/(features)/attendance.tsx
+++ b/app/(features)/attendance.tsx
@@ -6,7 +6,6 @@ import { Platform, ScrollView, View } from "react-native";
import { Papicons } from "@getpapillon/papicons"
import { useTheme } from "@react-navigation/native";
import { Dynamic } from "@/ui/components/Dynamic";
-import { MenuView } from "@react-native-menu/menu";
import { Period } from "@/services/shared/grade";
import { getPeriodName, getPeriodNumber, isPeriodWithNumber } from "@/utils/services/periods";
import { useMemo, useState } from "react";
@@ -21,6 +20,7 @@ import { error } from "@/utils/logger/logger";
import { getManager } from "@/services/shared";
import { t } from "i18next";
import i18n from "@/utils/i18n";
+import ActionMenu from "@/ui/components/ActionMenu";
export default function AttendanceView() {
try {
@@ -270,7 +270,7 @@ export default function AttendanceView() {
- {
const actionId = nativeEvent.event;
@@ -328,7 +328,7 @@ export default function AttendanceView() {
-
+
>
)}
diff --git a/app/(modals)/profile.tsx b/app/(modals)/profile.tsx
index 67d73520a..361262372 100644
--- a/app/(modals)/profile.tsx
+++ b/app/(modals)/profile.tsx
@@ -1,5 +1,4 @@
import { Papicons } from "@getpapillon/papicons";
-import { MenuView, NativeActionEvent } from "@react-native-menu/menu";
import { useHeaderHeight } from "@react-navigation/elements";
import { useTheme } from "@react-navigation/native";
import * as ImagePicker from "expo-image-picker"
@@ -23,6 +22,7 @@ import Icon from "@/ui/components/Icon";
import { NativeHeaderPressable, NativeHeaderSide } from "@/ui/components/NativeHeader";
import Typography from "@/ui/components/Typography";
import { getInitials } from "@/utils/chats/initials";
+import ActionMenu from "@/ui/components/ActionMenu";
export default function CustomProfileScreen() {
const { t } = useTranslation();
@@ -89,7 +89,7 @@ export default function CustomProfileScreen() {
imageUrl={profilePictureUrl || undefined}
/>
- }
title={t("Button_Change_ProfilePicture")}
/>
-
+
diff --git a/app/(modals)/wallpaper.tsx b/app/(modals)/wallpaper.tsx
index 09ce819e1..d92807893 100644
--- a/app/(modals)/wallpaper.tsx
+++ b/app/(modals)/wallpaper.tsx
@@ -1,4 +1,3 @@
-import { useAccountStore } from "@/stores/account"
import { useSettingsStore } from "@/stores/settings"
import { Wallpaper } from "@/stores/settings/types"
import AnimatedPressable from "@/ui/components/AnimatedPressable"
@@ -13,9 +12,9 @@ import { NativeHeaderPressable, NativeHeaderSide } from "@/ui/components/NativeH
import Icon from "@/ui/components/Icon"
import { router } from "expo-router";
import { Papicons } from "@getpapillon/papicons"
-import { MenuView } from "@react-native-menu/menu"
import * as ImagePicker from 'expo-image-picker';
+import ActionMenu from "@/ui/components/ActionMenu";
const COLLECTIONS_SOURCE = "https://raw.githubusercontent.com/PapillonApp/datasets/refs/heads/main/wallpapers/index.json";
@@ -235,7 +234,7 @@ const WallpaperModal = () => {
)}
- {
-
+
>
)
diff --git a/app/(tabs)/calendar/event/[id].tsx b/app/(tabs)/calendar/event/[id].tsx
index b34b2bf7c..4b0a72ded 100644
--- a/app/(tabs)/calendar/event/[id].tsx
+++ b/app/(tabs)/calendar/event/[id].tsx
@@ -1,4 +1,3 @@
-import { MenuView } from '@react-native-menu/menu';
import { useTheme } from "@react-navigation/native";
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router";
import { t } from 'i18next';
@@ -10,6 +9,7 @@ import UnderConstructionNotice from "@/components/UnderConstructionNotice";
import { useDatabase } from '@/database/DatabaseProvider';
import { useEventById } from '@/database/useEventsById';
import { NativeHeaderPressable, NativeHeaderSide } from '@/ui/components/NativeHeader';
+import ActionMenu from "@/ui/components/ActionMenu";
export default function TabOneScreen() {
const { id, title } = useLocalSearchParams();
@@ -39,7 +39,7 @@ export default function TabOneScreen() {
return (
<>
-
-
+
-
-
+
diff --git a/app/(tabs)/grades/index.tsx b/app/(tabs)/grades/index.tsx
index 61d91e9b2..d716c88e6 100644
--- a/app/(tabs)/grades/index.tsx
+++ b/app/(tabs)/grades/index.tsx
@@ -1,6 +1,6 @@
import { Papicons } from '@getpapillon/papicons';
import { LegendList } from '@legendapp/list';
-import { MenuView } from '@react-native-menu/menu';
+import ActionMenu from '@/ui/components/ActionMenu';
import { useTheme } from '@react-navigation/native';
import { useNavigation } from 'expo-router';
import { t } from 'i18next';
@@ -413,7 +413,7 @@ const GradesView: React.FC = () => {
onHeightChanged={setHeaderHeight}
/* Nom de la période */
title={
- {
const actionId = nativeEvent.event;
@@ -450,7 +450,7 @@ const GradesView: React.FC = () => {
loading={loading}
chevron={periods.length > 1}
/>
-
+
}
/* Filtres */
trailing={
@@ -534,4 +534,5 @@ const GradesView: React.FC = () => {
)
};
-export default GradesView;
\ No newline at end of file
+export default GradesView;
+
diff --git a/ui/components/ActionMenu.tsx b/ui/components/ActionMenu.tsx
new file mode 100644
index 000000000..81922abdd
--- /dev/null
+++ b/ui/components/ActionMenu.tsx
@@ -0,0 +1,324 @@
+import React, { useState, useRef } from "react";
+import type { ComponentType } from "react";
+import type { MenuAction as NativeMenuAction, MenuComponentProps as NativeMenuComponentProps } from "@react-native-menu/menu";
+import {
+ Modal,
+ Platform,
+ Pressable,
+ TouchableOpacity,
+ View,
+ StyleSheet,
+ Text,
+ LayoutRectangle,
+ Dimensions,
+} from "react-native";
+import { useTheme } from "@react-navigation/native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import Stack from "@/ui/components/Stack";
+import { Papicons } from "@getpapillon/papicons";
+import { runsIOS26 } from "@/ui/utils/IsLiquidGlass";
+import { warn } from "@/utils/logger/logger";
+
+let NativeMenuView: ComponentType> | null = null;
+if (Platform.OS === "ios") {
+ try {
+ const mod = require("@react-native-menu/menu");
+ NativeMenuView = mod?.MenuView ?? null;
+ } catch (err: unknown) {
+ warn(`ActionMenu: impossible de charger @react-native-menu/menu MenuView: ${String(err)}`);
+ }
+}
+
+function MenuItem({
+ action,
+ textColor,
+ subtitleColor,
+ primaryColor,
+ destructiveColor,
+ onPress,
+}: {
+ action: NativeMenuAction;
+ textColor: string;
+ subtitleColor: string;
+ primaryColor: string;
+ destructiveColor: string;
+ onPress: () => void;
+}) {
+ // Reduced flags: compute isOn from action.state (attributes does not expose state in the types).
+ const isOn = action.state === "on";
+ const hasSubactions = Boolean(action.subactions?.length);
+ const legacy = action as unknown as { destructive?: boolean; disabled?: boolean };
+ const destructive = Boolean(action.attributes?.destructive ?? legacy.destructive);
+ const disabled = Boolean(action.attributes?.disabled ?? legacy.disabled);
+
+ let colorText: string;
+ if (action.imageColor !== undefined && action.imageColor !== null) {
+ colorText = String(action.imageColor);
+ } else if (destructive) {
+ colorText = destructiveColor;
+ } else {
+ colorText = textColor;
+ }
+
+ return (
+
+
+
+
+ {action.title}
+
+ {action.subtitle && (
+
+ {action.subtitle}
+
+ )}
+
+ {hasSubactions && (
+
+ )}
+ {isOn && !hasSubactions && (
+
+ )}
+
+
+ );
+}
+
+export default function ActionMenu({
+ actions = [],
+ children,
+ onPressAction,
+ title,
+}: NativeMenuComponentProps) {
+ const handleActionPress = onPressAction ?? (() => { });
+ const { colors } = useTheme();
+ const insets = useSafeAreaInsets();
+ const subtitleColor = `${colors.text}80`;
+ const primaryColor = colors.primary;
+ const cardColor = colors.card;
+ const destructiveColor = (colors as any).danger;
+ const borderColor = colors.border;
+
+ const triggerRef = useRef(null);
+ const menuRef = useRef(null);
+ const [visible, setVisible] = useState(false);
+ const [submenuStack, setSubmenuStack] = useState([]);
+ const [position, setPosition] = useState(null);
+ const [menuSize, setMenuSize] = useState<{ width: number; height: number } | null>(null);
+
+ // iOS
+ if (Platform.OS === "ios" && NativeMenuView) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ // Android
+ function open() {
+ setVisible(true);
+ setTimeout(() => {
+ triggerRef.current?.measureInWindow((x, y, width, height) => {
+ setPosition({ x, y, width, height });
+ });
+ }, 0);
+ }
+
+ function close() {
+ setVisible(false);
+ setSubmenuStack([]);
+ }
+
+ function handlePress(action: NativeMenuAction, fallbackId: string) {
+ if (action.subactions && action.subactions.length > 0) {
+ setSubmenuStack((prev) => [...prev, action]);
+ return;
+ }
+ handleActionPress({ nativeEvent: { event: action.id ?? fallbackId } });
+ close();
+ }
+
+ function handleBack() {
+ setSubmenuStack((prev) => prev.slice(0, -1));
+ }
+
+ function getMenuPosition() {
+ if (!position || !menuSize) {
+ return { alignSelf: "center" as const };
+ }
+
+ const screen = Dimensions.get("window");
+ const MARGIN = 16;
+ const SPACING = 8;
+
+ const safeLeft = insets.left + MARGIN;
+ const safeRight = screen.width - insets.right - MARGIN;
+ const safeTop = insets.top + MARGIN;
+ const safeBottom = screen.height - insets.bottom - MARGIN;
+
+ const left = Math.min(
+ Math.max(position.x, safeLeft),
+ safeRight - menuSize.width
+ );
+
+ const topIfBelow = position.y + position.height + SPACING;
+ const hasSpaceBelow = topIfBelow + menuSize.height <= safeBottom;
+ const top = hasSpaceBelow
+ ? topIfBelow
+ : Math.max(safeTop, position.y - menuSize.height - SPACING);
+
+ return { position: "absolute" as const, top, left };
+ }
+
+ const currentSubmenu = submenuStack[submenuStack.length - 1];
+ const currentActions = currentSubmenu?.subactions ?? actions;
+
+ return (
+ {
+ if (!visible) {
+ e.stopPropagation();
+ open();
+ }
+ }}
+ >
+ {children}
+
+
+
+ {
+ const { width, height } = e.nativeEvent.layout;
+ setMenuSize({ width, height });
+ }}
+ style={[
+ styles.menu,
+ getMenuPosition(),
+ { backgroundColor: cardColor, width: Math.min(Dimensions.get("window").width * 0.85, 320) },
+ ]}
+ >
+ {currentSubmenu && (
+
+
+
+ {currentSubmenu.title}
+
+
+ )}
+ {currentActions.map((action, index) => (
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ backdrop: {
+ flex: 1,
+ backgroundColor: "rgba(0,0,0,0.35)",
+ },
+ container: {
+ position: "absolute",
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ justifyContent: "center",
+ alignItems: "center",
+ padding: 16,
+ },
+ menu: {
+ borderRadius: 14,
+ padding: 6,
+ shadowColor: "#000",
+ shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 0.15,
+ shadowRadius: 12,
+ elevation: 8,
+ },
+ header: {
+ paddingVertical: 10,
+ paddingHorizontal: 14,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ marginBottom: 4,
+ },
+ back: {
+ fontSize: 16,
+ fontWeight: "600",
+ lineHeight: 20,
+ },
+ item: {
+ minHeight: 48,
+ paddingVertical: 12,
+ paddingHorizontal: 14,
+ borderRadius: 10,
+ },
+ itemSelected: {
+ backgroundColor: "rgba(0,102,204,0.12)",
+ },
+ itemContent: {
+ flex: 1,
+ marginRight: 10,
+ },
+ itemTitle: {
+ fontSize: 16,
+ },
+ itemSubtitle: {
+ fontSize: 13,
+ marginTop: 2,
+ },
+ arrow: {
+ alignSelf: "center",
+ opacity: 0.7,
+ marginLeft: 4,
+ },
+ check: {
+ alignSelf: "center",
+ marginLeft: 6,
+ },
+ headerIcon: {
+ alignSelf: "center"
+ },
+ disabled: {
+ opacity: 0.4,
+ },
+});
diff --git a/ui/components/ChipButton.tsx b/ui/components/ChipButton.tsx
index b2cb2a543..50a8616ac 100644
--- a/ui/components/ChipButton.tsx
+++ b/ui/components/ChipButton.tsx
@@ -1,5 +1,5 @@
import { Papicons } from "@getpapillon/papicons";
-import { MenuAction, MenuView } from '@react-native-menu/menu';
+import { MenuAction } from '@react-native-menu/menu';
import { useTheme } from "@react-navigation/native";
import { LiquidGlassView } from '@sbaiahmed1/react-native-blur';
import React from "react";
@@ -11,6 +11,7 @@ import { Dynamic } from "./Dynamic";
import Icon from "./Icon";
import Stack from "./Stack";
import Typography from "./Typography";
+import ActionMenu from "./ActionMenu";
const ChipButton: React.FC void;
@@ -43,7 +44,7 @@ const ChipButton: React.FC
-
+
{icon &&
@@ -69,7 +70,7 @@ const ChipButton: React.FC
}
-
+
);
@@ -93,7 +94,7 @@ const ChipButton: React.FC
-
+
{icon &&
@@ -119,7 +120,7 @@ const ChipButton: React.FC
}
-
+
);