166 lines
4.9 KiB
TypeScript
166 lines
4.9 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import {
|
|
Animated,
|
|
Dimensions,
|
|
Easing,
|
|
Modal,
|
|
Pressable,
|
|
Text,
|
|
View,
|
|
} from 'react-native';
|
|
import {
|
|
initialWindowMetrics,
|
|
SafeAreaProvider,
|
|
SafeAreaView,
|
|
} from 'react-native-safe-area-context';
|
|
import { useAuthStore } from '@/stores/auth-store';
|
|
|
|
const SCREEN_WIDTH = Dimensions.get('window').width;
|
|
const DRAWER_WIDTH = Math.min(320, Math.round(SCREEN_WIDTH * 0.82));
|
|
const ANIM_MS = 220;
|
|
|
|
interface DrawerProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onNavigateAccount: () => void;
|
|
onNavigateSettings: () => void;
|
|
}
|
|
|
|
export function Drawer({ open, onClose, onNavigateAccount }: DrawerProps) {
|
|
// Lazy-init so each Animated.Value is created once; the setters are never
|
|
// called — the values are mutated internally by the native driver.
|
|
const [translateX] = useState(() => new Animated.Value(-DRAWER_WIDTH));
|
|
const [backdropOpacity] = useState(() => new Animated.Value(0));
|
|
|
|
useEffect(() => {
|
|
Animated.parallel([
|
|
Animated.timing(translateX, {
|
|
toValue: open ? 0 : -DRAWER_WIDTH,
|
|
duration: ANIM_MS,
|
|
easing: Easing.out(Easing.cubic),
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(backdropOpacity, {
|
|
toValue: open ? 0.4 : 0,
|
|
duration: ANIM_MS,
|
|
easing: Easing.out(Easing.cubic),
|
|
useNativeDriver: true,
|
|
}),
|
|
]).start();
|
|
}, [open, translateX, backdropOpacity]);
|
|
|
|
const user = useAuthStore((s) => s.user);
|
|
const signOut = useAuthStore((s) => s.signOut);
|
|
const isSigningOut = useAuthStore((s) => s.isSigningOut);
|
|
|
|
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? '??';
|
|
|
|
return (
|
|
<Modal
|
|
visible={open}
|
|
transparent
|
|
animationType="none"
|
|
onRequestClose={onClose}
|
|
>
|
|
{/* Modal mounts a separate native view tree on iOS — without a fresh
|
|
SafeAreaProvider seeded with initialWindowMetrics, useSafeAreaInsets
|
|
inside reports {0,0,0,0} on the first frame and content snaps from
|
|
the status bar down to the safe area once metrics resolve. */}
|
|
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
|
<View className="flex-1">
|
|
<Animated.View
|
|
pointerEvents={open ? 'auto' : 'none'}
|
|
style={{ opacity: backdropOpacity }}
|
|
className="absolute inset-0 bg-black"
|
|
>
|
|
<Pressable className="flex-1" onPress={onClose} />
|
|
</Animated.View>
|
|
|
|
<Animated.View
|
|
style={{
|
|
width: DRAWER_WIDTH,
|
|
transform: [{ translateX }],
|
|
}}
|
|
className="absolute left-0 top-0 bottom-0 bg-sidebar"
|
|
>
|
|
<SafeAreaView edges={['top', 'bottom', 'left']} className="flex-1">
|
|
<View className="border-sidebar-border border-b px-5 py-5 flex-row items-center gap-3">
|
|
<View className="bg-sidebar-accent h-10 w-10 items-center justify-center rounded-full">
|
|
<Text className="text-sidebar-accent-foreground text-sm font-semibold">
|
|
{initials}
|
|
</Text>
|
|
</View>
|
|
<View className="flex-1">
|
|
<Text
|
|
className="text-sidebar-foreground text-base font-medium"
|
|
numberOfLines={1}
|
|
>
|
|
{user?.email_prefix ?? ''}
|
|
</Text>
|
|
<Text
|
|
className="text-muted-foreground text-xs"
|
|
numberOfLines={1}
|
|
>
|
|
{user?.email ?? ''}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
<View className="flex-1 py-2">
|
|
<DrawerRow
|
|
label="Account"
|
|
onPress={() => {
|
|
onClose();
|
|
onNavigateAccount();
|
|
}}
|
|
/>
|
|
</View>
|
|
|
|
<View className="border-sidebar-border border-t px-2 py-2">
|
|
<DrawerRow
|
|
label={isSigningOut ? 'Signing out...' : 'Sign out'}
|
|
disabled={isSigningOut}
|
|
onPress={() => {
|
|
void signOut();
|
|
}}
|
|
tone="destructive"
|
|
/>
|
|
</View>
|
|
</SafeAreaView>
|
|
</Animated.View>
|
|
</View>
|
|
</SafeAreaProvider>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function DrawerRow({
|
|
label,
|
|
onPress,
|
|
disabled,
|
|
tone = 'default',
|
|
}: {
|
|
label: string;
|
|
onPress: () => void;
|
|
disabled?: boolean;
|
|
tone?: 'default' | 'destructive';
|
|
}) {
|
|
return (
|
|
<Pressable
|
|
onPress={onPress}
|
|
disabled={disabled}
|
|
className="px-5 py-3 active:bg-sidebar-accent"
|
|
>
|
|
<Text
|
|
className={`text-base font-medium ${
|
|
tone === 'destructive'
|
|
? 'text-destructive'
|
|
: 'text-sidebar-foreground'
|
|
} ${disabled ? 'opacity-50' : ''}`}
|
|
>
|
|
{label}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
}
|