/** * ErrorToast - Animated toast notification for errors * * Features: * - Slides in from top * - Auto-dismiss for non-critical errors * - Retry and dismiss actions * - Severity-based styling */ import React, { useEffect, useRef } from 'react'; import { View, Text, TouchableOpacity, StyleSheet, Animated, Platform, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; import { AppError, ErrorSeverity } from '@/types/errors'; interface ErrorToastProps { error: AppError & { onRetry?: () => void }; onDismiss: () => void; visible: boolean; } // Severity colors const severityColors: Record = { critical: { bg: '#FEE2E2', border: '#EF4444', icon: '#DC2626' }, error: { bg: '#FEE2E2', border: '#EF4444', icon: '#DC2626' }, warning: { bg: '#FEF3C7', border: '#F59E0B', icon: '#D97706' }, info: { bg: '#DBEAFE', border: '#3B82F6', icon: '#2563EB' }, }; // Severity icons const severityIcons: Record = { critical: 'alert-circle', error: 'close-circle', warning: 'warning', info: 'information-circle', }; export function ErrorToast({ error, onDismiss, visible }: ErrorToastProps) { const insets = useSafeAreaInsets(); const slideAnim = useRef(new Animated.Value(-200)).current; const opacityAnim = useRef(new Animated.Value(0)).current; const colors = severityColors[error.severity]; const icon = severityIcons[error.severity]; useEffect(() => { if (visible) { // Slide in Animated.parallel([ Animated.spring(slideAnim, { toValue: 0, useNativeDriver: true, tension: 50, friction: 8, }), Animated.timing(opacityAnim, { toValue: 1, duration: 200, useNativeDriver: true, }), ]).start(); } else { // Slide out Animated.parallel([ Animated.timing(slideAnim, { toValue: -200, duration: 200, useNativeDriver: true, }), Animated.timing(opacityAnim, { toValue: 0, duration: 200, useNativeDriver: true, }), ]).start(); } }, [visible, slideAnim, opacityAnim]); // Component always renders when not visible - animations handle visibility if (!visible) { // We still render to allow the slide-out animation to complete } return ( {/* Icon */} {/* Content */} {error.userMessage} {error.actionHint && ( {error.actionHint} )} {/* Actions */} {error.retry.isRetryable && error.onRetry && ( { onDismiss(); error.onRetry?.(); }} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} > )} ); } const styles = StyleSheet.create({ container: { position: 'absolute', top: 0, left: 0, right: 0, zIndex: 9999, paddingHorizontal: 16, }, toast: { flexDirection: 'row', alignItems: 'center', padding: 12, borderRadius: 12, borderWidth: 1, ...Platform.select({ ios: { shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.15, shadowRadius: 8, }, android: { elevation: 6, }, }), }, iconContainer: { marginRight: 12, }, content: { flex: 1, marginRight: 8, }, message: { fontSize: 14, fontWeight: '600', color: '#1F2937', lineHeight: 20, }, hint: { fontSize: 12, color: '#6B7280', marginTop: 2, }, actions: { flexDirection: 'row', alignItems: 'center', gap: 8, }, retryButton: { padding: 4, }, dismissButton: { padding: 4, }, }); export default ErrorToast;