From 51a522eafce88f28db4fdcf0ad2504947e4c743d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ya=C3=ABl=20Perret?= Date: Wed, 2 Sep 2026 16:03:32 +0200 Subject: [PATCH] =?UTF-8?q?feat=20:=20Premi=C3=A8re=20navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 110 +++++++ package.json | 1 + src/app/(org)/_layout.tsx | 72 ++++- src/app/(org)/create.tsx | 7 + src/app/(tabs)/_layout.tsx | 44 ++- src/app/_layout.tsx | 25 +- src/app/{index.tsx => index-old.tsx} | 0 src/app/index-old2.tsx | 5 + src/components/navigation/BahlaTabBar.tsx | 277 ++++++++++++++++++ .../organizer/CreateActionSheet.tsx | 150 ++++++++++ src/hooks/useTabBarHeight.ts | 18 ++ src/stores/authStore.ts | 20 +- 12 files changed, 714 insertions(+), 15 deletions(-) create mode 100644 src/app/(org)/create.tsx rename src/app/{index.tsx => index-old.tsx} (100%) create mode 100644 src/app/index-old2.tsx create mode 100644 src/components/navigation/BahlaTabBar.tsx create mode 100644 src/components/organizer/CreateActionSheet.tsx create mode 100644 src/hooks/useTabBarHeight.ts diff --git a/package-lock.json b/package-lock.json index 09798bb..05a9261 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@expo-google-fonts/jetbrains-mono": "^0.4.1", "@expo-google-fonts/space-grotesk": "^0.4.1", "@expo/ui": "~56.0.14", + "@react-navigation/bottom-tabs": "^7.18.18", "@tanstack/react-query": "^5.102.8", "axios": "^1.20.0", "babel-preset-expo": "^57.0.9", @@ -2867,6 +2868,96 @@ } } }, + "node_modules/@react-navigation/bottom-tabs": { + "version": "7.18.18", + "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.18.18.tgz", + "integrity": "sha512-8XEvMG7SeBEY1zPSM3Bt3T12db1Q+j9qAMakdoSVkbFBXo5+rbrsdz4kOVtCs7vXOEeJfOEIZlubgDb4zRFHAw==", + "license": "MIT", + "dependencies": { + "@react-navigation/elements": "^2.9.40", + "color": "^4.2.3", + "sf-symbols-typescript": "^2.1.0" + }, + "peerDependencies": { + "@react-navigation/native": "^7.3.18", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0", + "react-native-screens": ">= 4.0.0" + } + }, + "node_modules/@react-navigation/core": { + "version": "7.21.13", + "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.21.13.tgz", + "integrity": "sha512-d1syMXra7RlJjGsjsIxEA14F6U+awFtQJeOfDc+V7x3y1mPqYQVTYnqnt3PR+fBBouUKaKsgKZsSR3MP5919Xg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@react-navigation/routers": "^7.6.4", + "escape-string-regexp": "^4.0.0", + "fast-deep-equal": "^3.1.3", + "nanoid": "^3.3.11", + "query-string": "^7.1.3", + "react-is": "^19.1.0", + "use-latest-callback": "^0.2.4", + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "react": ">= 18.2.0" + } + }, + "node_modules/@react-navigation/elements": { + "version": "2.9.40", + "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.40.tgz", + "integrity": "sha512-x2fCMVKU23YeQvHSCFq2mVRYAbHqYNqb8kfWSEfIQ+XUX7dBXWd93/pr0w5jLjALDHDxQUaJKh4mIqr8q3zAwQ==", + "license": "MIT", + "dependencies": { + "color": "^4.2.3", + "use-latest-callback": "^0.2.4", + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@react-native-masked-view/masked-view": ">= 0.2.0", + "@react-navigation/native": "^7.3.18", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0" + }, + "peerDependenciesMeta": { + "@react-native-masked-view/masked-view": { + "optional": true + } + } + }, + "node_modules/@react-navigation/native": { + "version": "7.3.18", + "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.3.18.tgz", + "integrity": "sha512-5lLYl0Kt85jcpEYxXzj2ecIKta+yo4EcAIOZoJe6jU9lbR0lu6sAQsotU3rlVAYL1VBY/xLemxs4WQ2LJs4Oew==", + "license": "MIT", + "peer": true, + "dependencies": { + "@react-navigation/core": "^7.21.13", + "escape-string-regexp": "^4.0.0", + "fast-deep-equal": "^3.1.3", + "nanoid": "^3.3.11", + "standard-navigation": "^0.0.8", + "use-latest-callback": "^0.2.4" + }, + "peerDependencies": { + "react": ">= 18.2.0", + "react-native": "*" + } + }, + "node_modules/@react-navigation/routers": { + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.6.4.tgz", + "integrity": "sha512-GI7eJm8/KsZUQaYcXvEExikKurRZRgEsSzyZ7faENfi65yqJBCXjDMwyN1pF6pNW1MoLH1ErDwDivFxY6BzD3w==", + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.11" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -9666,6 +9757,16 @@ "node": ">=6" } }, + "node_modules/standard-navigation": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/standard-navigation/-/standard-navigation-0.0.8.tgz", + "integrity": "sha512-TyVbo7INUDWtsUWDFn8RR7kwR87U0S4xHfLfbbnyeC581TmmyqQ+eM+nPw8rQTSD8QitRVcYfPaSHr/QJiUy1g==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "react": "*" + } + }, "node_modules/statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", @@ -10191,6 +10292,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index 4f0a2e4..9fceb80 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "@expo-google-fonts/jetbrains-mono": "^0.4.1", "@expo-google-fonts/space-grotesk": "^0.4.1", "@expo/ui": "~56.0.14", + "@react-navigation/bottom-tabs": "^7.18.18", "@tanstack/react-query": "^5.102.8", "axios": "^1.20.0", "babel-preset-expo": "^57.0.9", diff --git a/src/app/(org)/_layout.tsx b/src/app/(org)/_layout.tsx index da5f029..6e73749 100644 --- a/src/app/(org)/_layout.tsx +++ b/src/app/(org)/_layout.tsx @@ -1,12 +1,78 @@ +import { BahlaTabBar } from '@/components/navigation/BahlaTabBar'; +import { CreateActionSheet } from '@/components/organizer/CreateActionSheet'; import { useAuth } from '@/stores/authStore'; -import { Redirect, Stack } from 'expo-router'; +import { colors } from '@/theme/colors'; +import { Redirect, Tabs, router } from 'expo-router'; +import { CalendarDays, ChartColumn, LayoutDashboard, UserRound } from 'lucide-react-native'; +import { useState } from 'react'; export default function OrgLayout() { const session = useAuth((state) => state.session); - + const [createOpen, setCreateOpen] = useState(false); + if (!session) return ; if (session.role !== 'org') return ; - return ; + return ( + <> + setCreateOpen(true)} />} + > + , + }} + /> + , + }} + /> + e.preventDefault() }} + /> + , + }} + /> + , + }} + /> + + + setCreateOpen(false)} + onCreateEvent={() => { + setCreateOpen(false); + router.push('/create/event'); + }} + onCreatePost={() => { + setCreateOpen(false); + router.push('/create/post'); + }} + /> + + ); } \ No newline at end of file diff --git a/src/app/(org)/create.tsx b/src/app/(org)/create.tsx new file mode 100644 index 0000000..b09e1ad --- /dev/null +++ b/src/app/(org)/create.tsx @@ -0,0 +1,7 @@ +/* ------------------------------------------------------------------ * + * Route factice : réserve l'emplacement central de la tab bar. + * Elle n'est jamais atteinte — le listener de (org)/_layout la bloque. + * ------------------------------------------------------------------ */ +export default function CreatePlaceholder() { + return null; +} diff --git a/src/app/(tabs)/_layout.tsx b/src/app/(tabs)/_layout.tsx index d9ec0e3..f229781 100644 --- a/src/app/(tabs)/_layout.tsx +++ b/src/app/(tabs)/_layout.tsx @@ -1,5 +1,8 @@ +import { BahlaTabBar } from '@/components/navigation/BahlaTabBar'; import { useAuth } from '@/stores/authStore'; -import { Redirect, Stack } from 'expo-router'; +import { colors } from '@/theme/colors'; +import { Redirect, Tabs } from 'expo-router'; +import { Compass, Map, Ticket, UserRound } from 'lucide-react-native'; export default function TabsLayout() { const session = useAuth((state) => state.session); @@ -8,5 +11,42 @@ export default function TabsLayout() { if (session.role === 'org') return ; - return ; + return ( + } + > + , + }} + /> + , + }} + /> + , + }} + /> + , + }} + /> + + ); } \ No newline at end of file diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index ade1a9d..f13524e 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -1,28 +1,27 @@ import { QueryClientProvider } from '@tanstack/react-query'; import { Stack } from 'expo-router'; import * as SplashScreen from 'expo-splash-screen'; -import { StatusBar } from 'expo-status-bar'; +import * as SystemUI from 'expo-system-ui'; import { useEffect } from 'react'; +import { StatusBar } from 'react-native'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { queryClient } from '../hooks/queryClient'; import { useAppFonts } from '../theme/typography'; import { useAuth } from '@/stores/authStore'; +import colors from '@/theme/colors'; SplashScreen.preventAutoHideAsync(); export default function RootLayout() { const [fontsLoaded] = useAppFonts(); - - if (!fontsLoaded) { - return null; - } - const hydrated = useAuth((state) => state.hydrated); const hydrate = useAuth((state) => state.hydrate); - useEffect(() => { hydrate(); }, []); + useEffect(() => { + hydrate(); + }, [hydrate]); const ready = hydrated && fontsLoaded; @@ -30,13 +29,21 @@ export default function RootLayout() { if (ready) SplashScreen.hideAsync(); }, [ready]); + useEffect(() => { + SystemUI.setBackgroundColorAsync(colors.deepBackground); + }, []); + if (!ready) return null; return ( - - + + diff --git a/src/app/index.tsx b/src/app/index-old.tsx similarity index 100% rename from src/app/index.tsx rename to src/app/index-old.tsx diff --git a/src/app/index-old2.tsx b/src/app/index-old2.tsx new file mode 100644 index 0000000..5164308 --- /dev/null +++ b/src/app/index-old2.tsx @@ -0,0 +1,5 @@ +import { Redirect } from 'expo-router'; + +export default function IndexRoute() { + return ; +} diff --git a/src/components/navigation/BahlaTabBar.tsx b/src/components/navigation/BahlaTabBar.tsx new file mode 100644 index 0000000..2ab060b --- /dev/null +++ b/src/components/navigation/BahlaTabBar.tsx @@ -0,0 +1,277 @@ +/* ------------------------------------------------------------------ * + * BahlaTabBar — barre d'onglets floutée, partagée par (tabs) et (org). + * + * Une seule implémentation pour les deux parcours : le verre dépoli, + * le calcul de safe area, le rendu des items et l'accessibilité sont + * identiques. Seul le bouton central change, et il est optionnel. + * + * Usage : + * }> // utilisateur + * }> + * + * Dépendances : expo-blur · expo-linear-gradient · + * react-native-safe-area-context · react-native-svg + * ------------------------------------------------------------------ */ +import { BlurView } from 'expo-blur'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Tabs } from 'expo-router'; +import { useCallback, type ComponentProps } from 'react'; +import { + Platform, + Pressable, + StyleSheet, + Text, + View, + type ViewStyle, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { colors, gradientDirection, gradients } from '@/theme/colors'; +import { fontFamily, typography } from '@/theme/typography'; +import { Plus } from 'lucide-react-native'; + +/** Hauteur de la barre hors safe area. La hauteur réelle y ajoute l'inset bas. */ +export const TAB_BAR_BASE_HEIGHT = 52; +const FAB_SIZE = 62; + +/** + * expo-blur ne rend rien d'exploitable sous Android 9 : sans ce repli on + * obtient un rectangle transparent et des libellés illisibles. + */ +const SUPPORTS_BLUR = Platform.OS === 'ios' || Number(Platform.Version) >= 28; + +type ExpoRouterTabBarProps = Parameters< + NonNullable['tabBar']> +>[0]; + +type Props = ExpoRouterTabBarProps & { + /** + * Nom de la route factice qui occupe l'emplacement central. + * Absent côté utilisateur — il n'y a pas de bouton flottant. + */ + fabRouteName?: string; + /** Appelé à la place d'une navigation quand on presse le bouton central. */ + onFabPress?: () => void; + /** Libellé lu par les lecteurs d'écran sur le bouton central. */ + fabAccessibilityLabel?: string; +}; + +export function BahlaTabBar({ + state, + descriptors, + navigation, + fabRouteName, + onFabPress, + fabAccessibilityLabel = 'Créer', +}: Props) { + const insets = useSafeAreaInsets(); + + const handlePress = useCallback( + (routeKey: string, routeName: string, isFocused: boolean) => { + const event = navigation.emit({ + type: 'tabPress', + target: routeKey, + canPreventDefault: true, + }); + if (isFocused || event.defaultPrevented) return; + navigation.navigate(routeName as never); + }, + [navigation], + ); + + const handleLongPress = useCallback( + (routeKey: string) => { + navigation.emit({ type: 'tabLongPress', target: routeKey }); + }, + [navigation], + ); + + return ( + + {state.routes.map((route: (typeof state.routes)[number], index: number) => { + const { options } = descriptors[route.key]; + const isFocused = state.index === index; + const tint = isFocused ? colors.primary : colors.textSecondary; + + /* --- Emplacement central : bouton flottant, pas un onglet ---- */ + if (fabRouteName && route.name === fabRouteName) { + return ( + + [ + styles.fab, + pressed && styles.fabPressed, + ]} + > + + + + + + ); + } + + /* --- Onglet ordinaire ---------------------------------------- */ + const label = + typeof options.tabBarLabel === 'string' + ? options.tabBarLabel + : (options.title ?? route.name); + + return ( + handlePress(route.key, route.name, isFocused)} + onLongPress={() => handleLongPress(route.key)} + style={styles.item} + > + + {options.tabBarIcon?.({ + focused: isFocused, + color: tint, + size: 23, + })} + {options.tabBarBadge != null && ( + + + {options.tabBarBadge} + + + )} + + + {label} + + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + bar: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + flexDirection: 'row', + alignItems: 'flex-start', + paddingTop: 8, + borderTopWidth: StyleSheet.hairlineWidth * 2, + borderTopColor: colors.border, + overflow: 'visible', + } as ViewStyle, + + item: { + flex: 1, + alignItems: 'center', + gap: 5, + // La zone tactile couvre toute la hauteur de la barre. + alignSelf: 'stretch', + paddingTop: 2, + }, + + label: { + ...typography.tabLabel, + fontFamily: undefined, // remplacé plus haut selon l'état + }, + + badge: { + position: 'absolute', + top: -4, + right: -8, + minWidth: 16, + height: 16, + paddingHorizontal: 4, + borderRadius: 999, + backgroundColor: colors.primary, + alignItems: 'center', + justifyContent: 'center', + }, + + badgeText: { + ...typography.overlineSm, + fontSize: 9, + lineHeight: 16, + letterSpacing: 0, + color: colors.onPrimary, + }, + + /* Le créneau central est plus étroit que les onglets : 84 px fixes, + comme la colonne du même nom dans la maquette. */ + fabSlot: { + width: 84, + alignItems: 'center', + }, + + fab: { + width: FAB_SIZE, + height: FAB_SIZE, + aspectRatio: 1, + borderRadius: 15, + // Remonte au-dessus de la barre et découpe un anneau dans le fond. + marginTop: -30, + overflow: 'hidden', + alignSelf: 'center', + borderWidth: 3, + borderColor: colors.deepBackground, + shadowColor: colors.primary, + shadowOffset: { width: 0, height: 14 }, + shadowOpacity: 0.85, + shadowRadius: 17, + elevation: 12, + }, + + fabPressed: { + transform: [{ scale: 0.94 }], + shadowOpacity: 0.5, + }, + + fabGradient: { + width: '100%', + height: '100%', + aspectRatio: 1, + borderRadius: 15, + alignItems: 'center', + justifyContent: 'center', + }, +}); + +export default BahlaTabBar; diff --git a/src/components/organizer/CreateActionSheet.tsx b/src/components/organizer/CreateActionSheet.tsx new file mode 100644 index 0000000..3ec5ad7 --- /dev/null +++ b/src/components/organizer/CreateActionSheet.tsx @@ -0,0 +1,150 @@ +/* ------------------------------------------------------------------ * + * CreateActionSheet — le choix ouvert par le bouton central. + * Deux actions, rien d'autre : créer un événement, publier un post. + * ------------------------------------------------------------------ */ +import { LinearGradient } from 'expo-linear-gradient'; +import { + Modal, + Pressable, + StyleSheet, + Text, + View, + type ViewStyle, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { colors, gradientDirection, gradients } from '@/theme/colors'; +import { typography } from '@/theme/typography'; +import { Plus } from 'lucide-react-native'; + +type Props = { + visible: boolean; + onClose: () => void; + onCreateEvent: () => void; + onCreatePost: () => void; +}; + +export function CreateActionSheet({ + visible, + onClose, + onCreateEvent, + onCreatePost, +}: Props) { + const insets = useSafeAreaInsets(); + + return ( + + + + + + [styles.row, pressed && styles.rowPressed]} + > + + + + + Créer un événement + Date, lieu, line-up, billetterie + + + + [styles.row, pressed && styles.rowPressed]} + > + + + + + Publier un post + Sur un de tes événements à venir + + + + + ); +} + +const styles = StyleSheet.create({ + scrim: { + flex: 1, + backgroundColor: 'rgba(6,6,12,0.62)', + } as ViewStyle, + + sheet: { + backgroundColor: colors.surface, + borderTopLeftRadius: 28, + borderTopRightRadius: 28, + borderTopWidth: 1, + borderTopColor: colors.border, + paddingHorizontal: 20, + paddingTop: 10, + gap: 10, + }, + + handle: { + width: 44, + height: 5, + borderRadius: 999, + backgroundColor: colors.border, + alignSelf: 'center', + marginBottom: 10, + }, + + row: { + flexDirection: 'row', + alignItems: 'center', + gap: 14, + padding: 12, + borderRadius: 18, + backgroundColor: colors.surfaceElevated, + borderWidth: 1, + borderColor: colors.border, + }, + + rowPressed: { opacity: 0.7 }, + + iconBox: { + width: 48, + height: 48, + borderRadius: 15, + alignItems: 'center', + justifyContent: 'center', + }, + + iconBoxPlain: { + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + }, + + rowText: { flex: 1, gap: 2 }, + + rowTitle: { + ...typography.label, + fontSize: 16, + color: colors.textPrimary, + }, + + rowSub: { + ...typography.caption, + color: colors.textSecondary, + }, +}); + +export default CreateActionSheet; diff --git a/src/hooks/useTabBarHeight.ts b/src/hooks/useTabBarHeight.ts new file mode 100644 index 0000000..d6cc9c5 --- /dev/null +++ b/src/hooks/useTabBarHeight.ts @@ -0,0 +1,18 @@ +/* ------------------------------------------------------------------ * + * useTabBarHeight — hauteur réelle de la BahlaTabBar. + * + * La barre est en position absolue au-dessus du contenu. Tout écran qui + * défile doit réserver cette hauteur, sinon sa dernière carte finit + * masquée derrière le verre dépoli : + * + * + * ------------------------------------------------------------------ */ +import { TAB_BAR_BASE_HEIGHT } from '@/components/navigation/BahlaTabBar'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +export function useTabBarHeight() { + const insets = useSafeAreaInsets(); + return TAB_BAR_BASE_HEIGHT + insets.bottom; +} + +export default useTabBarHeight; diff --git a/src/stores/authStore.ts b/src/stores/authStore.ts index 0e0a1c0..4ba9d05 100644 --- a/src/stores/authStore.ts +++ b/src/stores/authStore.ts @@ -8,6 +8,14 @@ export type Session = { role: Role; } +// Temporaire: bypass de l'auth pour avancer sur la navigation. +const DEV_MOCK_SESSION_ENABLED = true; +const DEV_MOCK_SESSION: Session = { + userId: 'dev-user', + token: 'dev-token', + role: 'org', +}; + type AuthState = { session: Session | null; hydrated: boolean; // Le disque a été lu, la réponse est fiable @@ -23,8 +31,18 @@ export const useAuth = create((set) => ({ hydrated: false, hydrate: async () => { + if (DEV_MOCK_SESSION_ENABLED) { + set({ session: DEV_MOCK_SESSION, hydrated: true }); + return; + } + const session = await SecureStore.getItemAsync(KEY); - set({ session: session ? JSON.parse(session) : null, hydrated: true }); + if (session) { + set({ session: JSON.parse(session), hydrated: true }); + return; + } + + set({ session: null, hydrated: true }); }, signIn: async (s: Session) => {