import { Text } from '@/components/ui/text';
import { AlertCircle, Check, Info, X } from 'lucide-react-native';
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
import {
Dimensions,
Platform,
TouchableOpacity,
View,
ViewStyle,
} from 'react-native';
import {
Gesture,
GestureDetector,
GestureHandlerRootView,
} from 'react-native-gesture-handler';
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
withDelay,
withSpring,
withTiming,
} from 'react-native-reanimated';
export type ToastVariant = 'default' | 'success' | 'error' | 'warning' | 'info';
export interface ToastData {
id: string;
title?: string;
description?: string;
variant?: ToastVariant;
duration?: number;
action?: {
label: string;
onPress: () => void;
};
}
interface ToastProps extends ToastData {
onDismiss: (id: string) => void;
index: number;
}
const { width: screenWidth } = Dimensions.get('window');
const DYNAMIC_ISLAND_HEIGHT = 37;
const EXPANDED_HEIGHT = 85;
const TOAST_MARGIN = 8;
const DYNAMIC_ISLAND_WIDTH = 126;
const EXPANDED_WIDTH = screenWidth - 32;
// Reanimated spring configuration
const SPRING_CONFIG = {
stiffness: 120,
damping: 8,
};
export function Toast({
id,
title,
description,
variant = 'default',
onDismiss,
index,
action,
}: ToastProps) {
const [isExpanded, setIsExpanded] = useState(false);
// Reanimated shared values
const translateY = useSharedValue(-100);
const translateX = useSharedValue(0);
const opacity = useSharedValue(0);
const scale = useSharedValue(0.8);
const width = useSharedValue(DYNAMIC_ISLAND_WIDTH);
const height = useSharedValue(DYNAMIC_ISLAND_HEIGHT);
const borderRadius = useSharedValue(18.5);
const contentOpacity = useSharedValue(0);
// Dynamic Island colors (dark theme optimized)
const backgroundColor = '#1C1C1E'; // iOS Dynamic Island background
const mutedTextColor = '#8E8E93'; // iOS secondary text color
useEffect(() => {
const hasContentToShow = Boolean(title || description || action);
if (hasContentToShow) {
// If there's content, start directly with expanded state
width.value = EXPANDED_WIDTH;
height.value = EXPANDED_HEIGHT;
borderRadius.value = 20;
setIsExpanded(true);
// Animate in expanded toast
translateY.value = withSpring(0, SPRING_CONFIG);
opacity.value = withTiming(1, { duration: 300 });
scale.value = withSpring(1, SPRING_CONFIG);
// CORRECTED LINE: Use withDelay to wrap withTiming
contentOpacity.value = withDelay(100, withTiming(1, { duration: 300 }));
} else {
// If no content, show compact Dynamic Island with icon only
setIsExpanded(false);
// Animate in compact toast
translateY.value = withSpring(0, SPRING_CONFIG);
opacity.value = withTiming(1, { duration: 200 });
scale.value = withSpring(1, SPRING_CONFIG);
}
}, []); // This effect should only run once when the toast mounts
const getVariantColor = () => {
switch (variant) {
case 'success':
return '#30D158'; // iOS green
case 'error':
return '#FF453A'; // iOS red
case 'warning':
return '#FF9F0A'; // iOS orange
case 'info':
return '#007AFF'; // iOS blue
default:
return '#8E8E93'; // iOS gray
}
};
const getIcon = () => {
const iconProps = { size: 16, color: getVariantColor() };
switch (variant) {
case 'success':
return ;
case 'error':
return ;
case 'warning':
return ;
case 'info':
return ;
default:
return null;
}
};
const dismiss = useCallback(() => {
// This function will be called from the UI thread
const onDismissAction = () => {
'worklet';
runOnJS(onDismiss)(id);
};
translateY.value = withSpring(-100, SPRING_CONFIG);
opacity.value = withTiming(0, { duration: 250 }, (finished) => {
if (finished) {
onDismissAction();
}
});
scale.value = withSpring(0.8, SPRING_CONFIG);
}, [id, onDismiss]);
const panGesture = Gesture.Pan()
.onUpdate((event) => {
translateX.value = event.translationX;
})
.onEnd((event) => {
const { translationX, velocityX } = event;
if (
Math.abs(translationX) > screenWidth * 0.25 ||
Math.abs(velocityX) > 800
) {
// Dismiss action to be called from the UI thread
const onDismissAction = () => {
'worklet';
runOnJS(onDismiss)(id);
};
// Animate out horizontally
translateX.value = withTiming(
translationX > 0 ? screenWidth : -screenWidth,
{ duration: 250 }
);
opacity.value = withTiming(0, { duration: 250 }, (finished) => {
if (finished) {
onDismissAction();
}
});
} else {
// Snap back with spring animation
translateX.value = withSpring(0, SPRING_CONFIG);
}
});
const getTopPosition = () => {
const statusBarHeight = Platform.OS === 'ios' ? 59 : 20;
return statusBarHeight + index * (EXPANDED_HEIGHT + TOAST_MARGIN);
};
// Animated styles
const animatedContainerStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
transform: [
{ translateY: translateY.value },
{ translateX: translateX.value },
{ scale: scale.value },
],
}));
const animatedIslandStyle = useAnimatedStyle(() => ({
width: width.value,
height: height.value,
borderRadius: borderRadius.value,
backgroundColor,
justifyContent: 'center',
alignItems: 'center',
overflow: 'hidden',
}));
const animatedContentStyle = useAnimatedStyle(() => ({
opacity: contentOpacity.value,
}));
const toastStyle: ViewStyle = {
position: 'absolute',
top: getTopPosition(),
alignSelf: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.25,
shadowRadius: 20,
elevation: 10,
zIndex: 1000 + index,
};
return (
{/* Compact state - just icon or indicator */}
{!isExpanded && (
{getIcon()}
)}
{/* Expanded state - full content */}
{isExpanded && (
{getIcon() && (
{getIcon()}
)}
{title && (
{title}
)}
{description && (
{description}
)}
{action && (
{action.label}
)}
)}
);
}
interface ToastContextType {
toast: (toast: Omit) => void;
success: (title: string, description?: string) => void;
error: (title: string, description?: string) => void;
warning: (title: string, description?: string) => void;
info: (title: string, description?: string) => void;
dismiss: (id: string) => void;
dismissAll: () => void;
}
const ToastContext = createContext(null);
interface ToastProviderProps {
children: React.ReactNode;
maxToasts?: number;
}
export function ToastProvider({ children, maxToasts = 3 }: ToastProviderProps) {
const [toasts, setToasts] = useState([]);
const generateId = () => Math.random().toString(36).substr(2, 9);
const addToast = useCallback(
(toastData: Omit) => {
const id = generateId();
const newToast: ToastData = {
...toastData,
id,
duration: toastData.duration ?? 4000,
};
setToasts((prev) => {
const updated = [newToast, ...prev];
return updated.slice(0, maxToasts);
});
// Auto dismiss after duration
if (newToast.duration && newToast.duration > 0) {
setTimeout(() => {
dismissToast(id);
}, newToast.duration);
}
},
[maxToasts]
);
const dismissToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
const dismissAll = useCallback(() => {
setToasts([]);
}, []);
const createVariantToast = useCallback(
(variant: ToastVariant, title: string, description?: string) => {
addToast({
title,
description,
variant,
});
},
[addToast]
);
const contextValue: ToastContextType = {
toast: addToast,
success: (title, description) =>
createVariantToast('success', title, description),
error: (title, description) =>
createVariantToast('error', title, description),
warning: (title, description) =>
createVariantToast('warning', title, description),
info: (title, description) =>
createVariantToast('info', title, description),
dismiss: dismissToast,
dismissAll,
};
const containerStyle: ViewStyle = {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 1000,
pointerEvents: 'box-none',
};
return (
{children}
{toasts.map((toast, index) => (
))}
);
}
// Hook to use toast
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
}