- Add extended error types with severity levels, retry policies, and contextual information (types/errors.ts) - Create centralized error handler service with user-friendly message translation and classification (services/errorHandler.ts) - Add ErrorContext for global error state management with auto-dismiss and error queue support (contexts/ErrorContext.tsx) - Create error UI components: ErrorToast, FieldError, FieldErrorSummary, FullScreenError, EmptyState, OfflineState - Add useError hook with retry strategies and API response handling - Add useAsync hook for async operations with comprehensive state - Create error message utilities with validation helpers - Add tests for errorHandler and errorMessages (88 tests) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
216 lines
5.1 KiB
TypeScript
216 lines
5.1 KiB
TypeScript
/**
|
|
* 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<ErrorSeverity, { bg: string; border: string; icon: string }> = {
|
|
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<ErrorSeverity, keyof typeof Ionicons.glyphMap> = {
|
|
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 (
|
|
<Animated.View
|
|
style={[
|
|
styles.container,
|
|
{
|
|
paddingTop: insets.top + 8,
|
|
transform: [{ translateY: slideAnim }],
|
|
opacity: opacityAnim,
|
|
},
|
|
]}
|
|
pointerEvents="box-none"
|
|
>
|
|
<View
|
|
style={[
|
|
styles.toast,
|
|
{
|
|
backgroundColor: colors.bg,
|
|
borderColor: colors.border,
|
|
},
|
|
]}
|
|
>
|
|
{/* Icon */}
|
|
<View style={styles.iconContainer}>
|
|
<Ionicons name={icon} size={24} color={colors.icon} />
|
|
</View>
|
|
|
|
{/* Content */}
|
|
<View style={styles.content}>
|
|
<Text style={styles.message} numberOfLines={2}>
|
|
{error.userMessage}
|
|
</Text>
|
|
{error.actionHint && (
|
|
<Text style={styles.hint} numberOfLines={1}>
|
|
{error.actionHint}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
|
|
{/* Actions */}
|
|
<View style={styles.actions}>
|
|
{error.retry.isRetryable && error.onRetry && (
|
|
<TouchableOpacity
|
|
style={styles.retryButton}
|
|
onPress={() => {
|
|
onDismiss();
|
|
error.onRetry?.();
|
|
}}
|
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
>
|
|
<Ionicons name="refresh" size={20} color={colors.icon} />
|
|
</TouchableOpacity>
|
|
)}
|
|
<TouchableOpacity
|
|
style={styles.dismissButton}
|
|
onPress={onDismiss}
|
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
>
|
|
<Ionicons name="close" size={20} color="#6B7280" />
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
</Animated.View>
|
|
);
|
|
}
|
|
|
|
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;
|