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) => ( + handlePress(action, `action-${submenuStack.length}-${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 } - + );