148 lines
3.6 KiB
TypeScript
148 lines
3.6 KiB
TypeScript
import React, { useEffect } from "react";
|
|
import { View, StyleSheet, Dimensions } from "react-native";
|
|
import { Text } from "@/components/ui/text";
|
|
import { useToast, ToastType } from "@/lib/toast-store";
|
|
import {
|
|
CheckCircle2,
|
|
AlertCircle,
|
|
AlertTriangle,
|
|
Lightbulb,
|
|
} from "@/lib/icons";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
import Animated, {
|
|
useSharedValue,
|
|
useAnimatedStyle,
|
|
withSpring,
|
|
withTiming,
|
|
runOnJS,
|
|
} from "react-native-reanimated";
|
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
|
|
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
|
const SWIPE_THRESHOLD = SCREEN_WIDTH * 0.4;
|
|
|
|
const TOAST_VARIANTS: Record<
|
|
ToastType,
|
|
{
|
|
bg: string;
|
|
border: string;
|
|
icon: React.ReactNode;
|
|
}
|
|
> = {
|
|
success: {
|
|
bg: "#f0fdf4",
|
|
border: "#22c55e",
|
|
icon: <CheckCircle2 size={24} color="#22c55e" />,
|
|
},
|
|
info: {
|
|
bg: "#f0f9ff",
|
|
border: "#0ea5e9",
|
|
icon: <Lightbulb size={24} color="#0ea5e9" />,
|
|
},
|
|
warning: {
|
|
bg: "#fffbeb",
|
|
border: "#f59e0b",
|
|
icon: <AlertTriangle size={24} color="#f59e0b" />,
|
|
},
|
|
error: {
|
|
bg: "#fef2f2",
|
|
border: "#ef4444",
|
|
icon: <AlertCircle size={24} color="#ef4444" />,
|
|
},
|
|
};
|
|
|
|
export function Toast() {
|
|
const { visible, type, title, message, hide, duration } = useToast();
|
|
const insets = useSafeAreaInsets();
|
|
|
|
const opacity = useSharedValue(0);
|
|
const translateY = useSharedValue(-100);
|
|
const translateX = useSharedValue(0);
|
|
|
|
useEffect(() => {
|
|
if (visible) {
|
|
opacity.value = withTiming(1, { duration: 300 });
|
|
translateY.value = withSpring(0, { damping: 15, stiffness: 100 });
|
|
translateX.value = 0;
|
|
|
|
const timer = setTimeout(() => {
|
|
handleHide();
|
|
}, duration);
|
|
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [visible]);
|
|
|
|
const handleHide = () => {
|
|
opacity.value = withTiming(0, { duration: 300 });
|
|
translateY.value = withTiming(-100, { duration: 300 }, () => {
|
|
runOnJS(hide)();
|
|
});
|
|
};
|
|
|
|
const swipeGesture = Gesture.Pan()
|
|
.onUpdate((event) => {
|
|
translateX.value = event.translationX;
|
|
})
|
|
.onEnd((event) => {
|
|
if (Math.abs(event.translationX) > SWIPE_THRESHOLD) {
|
|
translateX.value = withTiming(
|
|
event.translationX > 0 ? SCREEN_WIDTH : -SCREEN_WIDTH,
|
|
{ duration: 200 },
|
|
() => runOnJS(handleHide)(),
|
|
);
|
|
} else {
|
|
translateX.value = withSpring(0);
|
|
}
|
|
});
|
|
|
|
const animatedStyle = useAnimatedStyle(() => ({
|
|
opacity: opacity.value,
|
|
transform: [
|
|
{ translateY: translateY.value },
|
|
{ translateX: translateX.value },
|
|
],
|
|
}));
|
|
|
|
if (!visible) return null;
|
|
|
|
const variant = TOAST_VARIANTS[type];
|
|
|
|
return (
|
|
<GestureDetector gesture={swipeGesture}>
|
|
<Animated.View
|
|
style={[
|
|
styles.container,
|
|
{
|
|
top: insets.top + 10,
|
|
backgroundColor: variant.bg,
|
|
borderColor: variant.border,
|
|
},
|
|
animatedStyle,
|
|
]}
|
|
className="border-2 rounded-2xl shadow-xl flex-row items-center p-4 pr-10"
|
|
>
|
|
<View className="mr-4">{variant.icon}</View>
|
|
|
|
<View className="flex-1">
|
|
<Text className="font-bold text-[14px] text-foreground mb-1 leading-tight">
|
|
{title}
|
|
</Text>
|
|
<Text className="text-muted-foreground text-xs font-medium leading-normal">
|
|
{message}
|
|
</Text>
|
|
</View>
|
|
</Animated.View>
|
|
</GestureDetector>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
position: "absolute",
|
|
left: 16,
|
|
right: 16,
|
|
zIndex: 9999,
|
|
},
|
|
});
|