128 lines
3.7 KiB
TypeScript
128 lines
3.7 KiB
TypeScript
import React, { createContext, useContext, useState, useCallback, useRef } from 'react';
|
|
import { View, Text, Animated, TouchableOpacity, StyleSheet } from 'react-native';
|
|
import { CheckCircle, AlertTriangle, XCircle, X } from 'lucide-react-native';
|
|
|
|
export type ToastType = 'success' | 'warning' | 'error';
|
|
|
|
interface Toast {
|
|
id: string;
|
|
message: string;
|
|
type: ToastType;
|
|
}
|
|
|
|
interface ToastContextValue {
|
|
showToast: (message: string, type?: ToastType) => void;
|
|
}
|
|
|
|
const ToastContext = createContext<ToastContextValue>({ showToast: () => {} });
|
|
|
|
const COLORS: Record<ToastType, string> = {
|
|
success: '#4CAF7D',
|
|
warning: '#D4A843',
|
|
error: '#C0392B',
|
|
};
|
|
|
|
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: string) => void }) {
|
|
const opacity = useRef(new Animated.Value(0)).current;
|
|
const translateY = useRef(new Animated.Value(-20)).current;
|
|
|
|
React.useEffect(() => {
|
|
Animated.parallel([
|
|
Animated.timing(opacity, { toValue: 1, duration: 250, useNativeDriver: true }),
|
|
Animated.timing(translateY, { toValue: 0, duration: 250, useNativeDriver: true }),
|
|
]).start();
|
|
|
|
const timer = setTimeout(() => dismiss(), 3000);
|
|
return () => clearTimeout(timer);
|
|
}, []);
|
|
|
|
function dismiss() {
|
|
Animated.parallel([
|
|
Animated.timing(opacity, { toValue: 0, duration: 200, useNativeDriver: true }),
|
|
Animated.timing(translateY, { toValue: -20, duration: 200, useNativeDriver: true }),
|
|
]).start(() => onDismiss(toast.id));
|
|
}
|
|
|
|
const color = COLORS[toast.type];
|
|
const Icon = toast.type === 'success' ? CheckCircle : toast.type === 'warning' ? AlertTriangle : XCircle;
|
|
|
|
return (
|
|
<Animated.View
|
|
style={[styles.toast, { backgroundColor: color, opacity, transform: [{ translateY }] }]}
|
|
accessibilityRole="alert"
|
|
accessibilityLabel={`${toast.type}: ${toast.message}`}
|
|
>
|
|
<Icon size={18} color="white" />
|
|
<Text style={styles.message} numberOfLines={3}>{toast.message}</Text>
|
|
<TouchableOpacity
|
|
onPress={dismiss}
|
|
accessibilityLabel="Dismiss notification"
|
|
accessibilityRole="button"
|
|
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
|
>
|
|
<X size={16} color="rgba(255,255,255,0.8)" />
|
|
</TouchableOpacity>
|
|
</Animated.View>
|
|
);
|
|
}
|
|
|
|
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
const counterRef = useRef(0);
|
|
|
|
const showToast = useCallback((message: string, type: ToastType = 'success') => {
|
|
const id = `toast-${++counterRef.current}-${Date.now()}`;
|
|
setToasts((prev) => [...prev, { id, message, type }]);
|
|
}, []);
|
|
|
|
const dismissToast = useCallback((id: string) => {
|
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
}, []);
|
|
|
|
return (
|
|
<ToastContext.Provider value={{ showToast }}>
|
|
{children}
|
|
<View style={styles.container} pointerEvents="box-none">
|
|
{toasts.map((toast) => (
|
|
<ToastItem key={toast.id} toast={toast} onDismiss={dismissToast} />
|
|
))}
|
|
</View>
|
|
</ToastContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useToast(): ToastContextValue {
|
|
return useContext(ToastContext);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
position: 'absolute',
|
|
bottom: 90,
|
|
left: 16,
|
|
right: 16,
|
|
gap: 8,
|
|
zIndex: 9999,
|
|
},
|
|
toast: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
borderRadius: 12,
|
|
paddingVertical: 12,
|
|
paddingHorizontal: 14,
|
|
gap: 10,
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 2 },
|
|
shadowOpacity: 0.2,
|
|
shadowRadius: 4,
|
|
elevation: 6,
|
|
},
|
|
message: {
|
|
flex: 1,
|
|
color: 'white',
|
|
fontSize: 14,
|
|
fontWeight: '500',
|
|
lineHeight: 20,
|
|
},
|
|
});
|