Add comprehensive error handling system

- 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>
This commit is contained in:
Sergei 2026-01-31 17:43:07 -08:00
parent 9d5a40944f
commit a238b7e35f
12 changed files with 3377 additions and 0 deletions

View File

@ -0,0 +1,371 @@
/**
* Tests for Error Handler Service
*/
import {
createErrorFromApi,
createErrorFromException,
createNetworkError,
createValidationError,
createErrorFromUnknown,
createError,
calculateRetryDelay,
ErrorCodes,
} from '@/services/errorHandler';
import { ErrorCategory, ErrorSeverity, isAppError, isRetryableError, isAuthError } from '@/types/errors';
describe('errorHandler', () => {
describe('createErrorFromApi', () => {
it('should create error from API error with code', () => {
const apiError = {
message: 'Invalid email address',
code: 'INVALID_EMAIL',
status: 400,
};
const error = createErrorFromApi(apiError);
expect(error.message).toBe('Invalid email address');
expect(error.code).toBe('INVALID_EMAIL');
expect(error.status).toBe(400);
expect(error.category).toBe('validation');
expect(error.severity).toBe('error');
expect(error.errorId).toBeDefined();
expect(error.timestamp).toBeInstanceOf(Date);
});
it('should classify 401 status as authentication error', () => {
const apiError = {
message: 'Unauthorized',
status: 401,
};
const error = createErrorFromApi(apiError);
expect(error.category).toBe('authentication');
expect(error.retry.isRetryable).toBe(false);
});
it('should classify 404 status as notFound error', () => {
const apiError = {
message: 'Not found',
status: 404,
};
const error = createErrorFromApi(apiError);
expect(error.category).toBe('notFound');
expect(error.retry.isRetryable).toBe(false);
});
it('should classify 500 status as server error with retry', () => {
const apiError = {
message: 'Internal server error',
status: 500,
};
const error = createErrorFromApi(apiError);
expect(error.category).toBe('server');
expect(error.retry.isRetryable).toBe(true);
});
it('should classify 429 status as rate limit error', () => {
const apiError = {
message: 'Too many requests',
status: 429,
};
const error = createErrorFromApi(apiError);
expect(error.category).toBe('rateLimit');
expect(error.retry.isRetryable).toBe(true);
});
it('should include context in error', () => {
const apiError = { message: 'Error', code: 'TEST' };
const context = { userId: 123, action: 'test' };
const error = createErrorFromApi(apiError, context);
expect(error.context).toEqual(context);
});
});
describe('createErrorFromException', () => {
it('should create error from JavaScript Error', () => {
const jsError = new Error('Something went wrong');
const error = createErrorFromException(jsError);
expect(error.message).toBe('Something went wrong');
expect(error.code).toBe(ErrorCodes.EXCEPTION);
expect(error.originalError).toBe(jsError);
});
it('should use custom code when provided', () => {
const jsError = new Error('Network failed');
const error = createErrorFromException(jsError, ErrorCodes.NETWORK_ERROR);
expect(error.code).toBe(ErrorCodes.NETWORK_ERROR);
expect(error.category).toBe('network');
});
});
describe('createNetworkError', () => {
it('should create network error with default message', () => {
const error = createNetworkError();
expect(error.code).toBe(ErrorCodes.NETWORK_ERROR);
expect(error.category).toBe('network');
expect(error.severity).toBe('warning');
expect(error.retry.isRetryable).toBe(true);
});
it('should create timeout error when specified', () => {
const error = createNetworkError('Request timed out', true);
expect(error.code).toBe(ErrorCodes.NETWORK_TIMEOUT);
expect(error.category).toBe('timeout');
expect(error.retry.isRetryable).toBe(true);
});
});
describe('createValidationError', () => {
it('should create validation error with field errors', () => {
const fieldErrors = [
{ field: 'email', message: 'Invalid email' },
{ field: 'phone', message: 'Invalid phone' },
];
const error = createValidationError('Validation failed', fieldErrors);
expect(error.code).toBe(ErrorCodes.VALIDATION_ERROR);
expect(error.category).toBe('validation');
expect(error.fieldErrors).toEqual(fieldErrors);
expect(error.retry.isRetryable).toBe(false);
});
});
describe('createErrorFromUnknown', () => {
it('should handle string errors', () => {
const error = createErrorFromUnknown('Something bad');
expect(error.message).toBe('Something bad');
});
it('should handle Error objects', () => {
const jsError = new Error('Test error');
const error = createErrorFromUnknown(jsError);
expect(error.message).toBe('Test error');
expect(error.originalError).toBe(jsError);
});
it('should handle API-like objects', () => {
const apiLike = { message: 'API error', code: 'TEST_CODE' };
const error = createErrorFromUnknown(apiLike);
expect(error.message).toBe('API error');
expect(error.code).toBe('TEST_CODE');
});
it('should handle null/undefined', () => {
const error = createErrorFromUnknown(null);
expect(error.message).toBe('An unknown error occurred');
expect(error.code).toBe(ErrorCodes.UNKNOWN_ERROR);
});
it('should pass through AppError objects', () => {
const original = createError(ErrorCodes.NETWORK_ERROR);
const result = createErrorFromUnknown(original);
expect(result.errorId).toBe(original.errorId);
});
});
describe('createError', () => {
it('should create error with predefined code', () => {
const error = createError(ErrorCodes.SUBSCRIPTION_REQUIRED);
expect(error.code).toBe(ErrorCodes.SUBSCRIPTION_REQUIRED);
expect(error.category).toBe('subscription');
expect(error.userMessage).toBeDefined();
});
it('should use custom message when provided', () => {
const error = createError(ErrorCodes.NOT_FOUND, 'User not found');
expect(error.message).toBe('User not found');
});
});
describe('calculateRetryDelay', () => {
it('should return base delay without exponential backoff', () => {
const policy = {
isRetryable: true,
retryDelayMs: 1000,
useExponentialBackoff: false,
};
expect(calculateRetryDelay(policy, 0)).toBe(1000);
expect(calculateRetryDelay(policy, 1)).toBe(1000);
expect(calculateRetryDelay(policy, 2)).toBe(1000);
});
it('should increase delay with exponential backoff', () => {
const policy = {
isRetryable: true,
retryDelayMs: 1000,
useExponentialBackoff: true,
};
const delay0 = calculateRetryDelay(policy, 0);
const delay1 = calculateRetryDelay(policy, 1);
const delay2 = calculateRetryDelay(policy, 2);
// With jitter, delays should be approximately 1000, 2000, 4000
expect(delay0).toBeGreaterThanOrEqual(1000);
expect(delay0).toBeLessThan(1300); // 1000 + 30% jitter
expect(delay1).toBeGreaterThanOrEqual(2000);
expect(delay1).toBeLessThan(2600);
expect(delay2).toBeGreaterThanOrEqual(4000);
expect(delay2).toBeLessThan(5200);
});
it('should cap delay at 30 seconds', () => {
const policy = {
isRetryable: true,
retryDelayMs: 10000,
useExponentialBackoff: true,
};
const delay = calculateRetryDelay(policy, 5); // Would be 320000ms without cap
expect(delay).toBeLessThanOrEqual(30000);
});
it('should return 1000ms when no delay specified', () => {
const policy = { isRetryable: true };
expect(calculateRetryDelay(policy, 0)).toBe(1000);
});
});
describe('type guards', () => {
describe('isAppError', () => {
it('should return true for valid AppError', () => {
const error = createError(ErrorCodes.NETWORK_ERROR);
expect(isAppError(error)).toBe(true);
});
it('should return false for plain objects', () => {
expect(isAppError({ message: 'test' })).toBe(false);
});
it('should return false for null/undefined', () => {
expect(isAppError(null)).toBe(false);
expect(isAppError(undefined)).toBe(false);
});
});
describe('isRetryableError', () => {
it('should return true for network errors', () => {
const error = createNetworkError();
expect(isRetryableError(error)).toBe(true);
});
it('should return false for auth errors', () => {
const error = createError(ErrorCodes.UNAUTHORIZED);
expect(isRetryableError(error)).toBe(false);
});
it('should return false for validation errors', () => {
const error = createValidationError('Invalid input');
expect(isRetryableError(error)).toBe(false);
});
});
describe('isAuthError', () => {
it('should return true for unauthorized errors', () => {
const error = createError(ErrorCodes.UNAUTHORIZED);
expect(isAuthError(error)).toBe(true);
});
it('should return true for token expired errors', () => {
const error = createError(ErrorCodes.TOKEN_EXPIRED);
expect(isAuthError(error)).toBe(true);
});
it('should return false for other errors', () => {
const error = createNetworkError();
expect(isAuthError(error)).toBe(false);
});
});
});
describe('error code classification', () => {
const testCases: [string, ErrorCategory][] = [
[ErrorCodes.NETWORK_ERROR, 'network'],
[ErrorCodes.NETWORK_TIMEOUT, 'timeout'],
[ErrorCodes.UNAUTHORIZED, 'authentication'],
[ErrorCodes.TOKEN_EXPIRED, 'authentication'],
[ErrorCodes.FORBIDDEN, 'permission'],
[ErrorCodes.NOT_FOUND, 'notFound'],
[ErrorCodes.BENEFICIARY_NOT_FOUND, 'notFound'],
[ErrorCodes.CONFLICT, 'conflict'],
[ErrorCodes.DUPLICATE_ENTRY, 'conflict'],
[ErrorCodes.VALIDATION_ERROR, 'validation'],
[ErrorCodes.INVALID_EMAIL, 'validation'],
[ErrorCodes.RATE_LIMITED, 'rateLimit'],
[ErrorCodes.SERVER_ERROR, 'server'],
[ErrorCodes.BLE_NOT_AVAILABLE, 'ble'],
[ErrorCodes.SENSOR_OFFLINE, 'sensor'],
[ErrorCodes.SUBSCRIPTION_REQUIRED, 'subscription'],
];
it.each(testCases)('should classify %s as %s category', (code, expectedCategory) => {
const error = createError(code as any);
expect(error.category).toBe(expectedCategory);
});
});
describe('user messages', () => {
it('should provide user-friendly message for network error', () => {
const error = createError(ErrorCodes.NETWORK_ERROR);
expect(error.userMessage).toContain('internet');
});
it('should provide user-friendly message for auth error', () => {
const error = createError(ErrorCodes.UNAUTHORIZED);
expect(error.userMessage).toContain('session');
});
it('should provide user-friendly message for subscription error', () => {
const error = createError(ErrorCodes.SUBSCRIPTION_REQUIRED);
expect(error.userMessage).toContain('subscription');
});
});
describe('action hints', () => {
it('should provide action hint for network error', () => {
const error = createError(ErrorCodes.NETWORK_ERROR);
expect(error.actionHint).toBeDefined();
expect(error.actionHint).toContain('connection');
});
it('should provide action hint for auth error', () => {
const error = createError(ErrorCodes.UNAUTHORIZED);
expect(error.actionHint).toBeDefined();
expect(error.actionHint).toContain('log in');
});
it('should provide action hint for BLE error', () => {
const error = createError(ErrorCodes.BLE_NOT_ENABLED);
expect(error.actionHint).toBeDefined();
expect(error.actionHint).toContain('Bluetooth');
});
});
});

View File

@ -0,0 +1,226 @@
/**
* Tests for Error Message Utilities
*/
import {
getErrorMessage,
getActionHint,
validateEmail,
validatePhone,
validateRequired,
validateMinLength,
validateMaxLength,
validateOtp,
formatErrorMessage,
combineFieldErrors,
getErrorCountText,
ErrorMessages,
} from '@/utils/errorMessages';
import { ErrorCodes } from '@/types/errors';
describe('errorMessages', () => {
describe('getErrorMessage', () => {
it('should return network error message', () => {
const message = getErrorMessage(ErrorCodes.NETWORK_ERROR);
expect(message).toBe(ErrorMessages.networkError);
});
it('should return session expired message for unauthorized', () => {
const message = getErrorMessage(ErrorCodes.UNAUTHORIZED);
expect(message).toBe(ErrorMessages.sessionExpired);
});
it('should return fallback for unknown code', () => {
const message = getErrorMessage('UNKNOWN_CODE', 'Custom fallback');
expect(message).toBe('Custom fallback');
});
it('should return generic error for unknown code without fallback', () => {
const message = getErrorMessage('UNKNOWN_CODE');
expect(message).toBe(ErrorMessages.genericError);
});
});
describe('getActionHint', () => {
it('should return hint for network error', () => {
const hint = getActionHint(ErrorCodes.NETWORK_ERROR);
expect(hint).toContain('connection');
});
it('should return hint for BLE error', () => {
const hint = getActionHint(ErrorCodes.BLE_NOT_ENABLED);
expect(hint).toContain('Bluetooth');
});
it('should return undefined for code without hint', () => {
const hint = getActionHint('UNKNOWN_CODE');
expect(hint).toBeUndefined();
});
});
describe('validateEmail', () => {
it('should return null for valid email', () => {
expect(validateEmail('test@example.com')).toBeNull();
expect(validateEmail('user.name@domain.co.uk')).toBeNull();
expect(validateEmail('user+tag@example.org')).toBeNull();
});
it('should return error for empty email', () => {
expect(validateEmail('')).toBe(ErrorMessages.required);
expect(validateEmail(' ')).toBe(ErrorMessages.required);
});
it('should return error for invalid email', () => {
expect(validateEmail('notanemail')).toBe(ErrorMessages.invalidEmail);
expect(validateEmail('missing@domain')).toBe(ErrorMessages.invalidEmail);
expect(validateEmail('@nodomain.com')).toBe(ErrorMessages.invalidEmail);
expect(validateEmail('spaces in@email.com')).toBe(ErrorMessages.invalidEmail);
});
});
describe('validatePhone', () => {
it('should return null for valid phone numbers', () => {
expect(validatePhone('+14155551234')).toBeNull();
expect(validatePhone('14155551234')).toBeNull();
expect(validatePhone('+7 (999) 123-45-67')).toBeNull();
expect(validatePhone('999.123.4567')).toBeNull();
});
it('should return null for empty phone (optional)', () => {
expect(validatePhone('')).toBeNull();
expect(validatePhone(' ')).toBeNull();
});
it('should return error for invalid phone', () => {
expect(validatePhone('123')).toBe(ErrorMessages.invalidPhone);
expect(validatePhone('abcdefghij')).toBe(ErrorMessages.invalidPhone);
});
});
describe('validateRequired', () => {
it('should return null for non-empty value', () => {
expect(validateRequired('test')).toBeNull();
expect(validateRequired(' test ')).toBeNull();
});
it('should return error for empty value', () => {
expect(validateRequired('')).toBe(ErrorMessages.required);
expect(validateRequired(' ')).toBe(ErrorMessages.required);
expect(validateRequired(null)).toBe(ErrorMessages.required);
expect(validateRequired(undefined)).toBe(ErrorMessages.required);
});
it('should include field name in error when provided', () => {
const error = validateRequired('', 'Email');
expect(error).toBe('Email is required.');
});
});
describe('validateMinLength', () => {
it('should return null when value meets minimum', () => {
expect(validateMinLength('abc', 3)).toBeNull();
expect(validateMinLength('abcde', 3)).toBeNull();
});
it('should return error when value is too short', () => {
expect(validateMinLength('ab', 3)).toBe(ErrorMessages.tooShort(3));
expect(validateMinLength('', 1)).toBe(ErrorMessages.tooShort(1));
});
});
describe('validateMaxLength', () => {
it('should return null when value meets maximum', () => {
expect(validateMaxLength('abc', 3)).toBeNull();
expect(validateMaxLength('ab', 3)).toBeNull();
});
it('should return error when value is too long', () => {
expect(validateMaxLength('abcd', 3)).toBe(ErrorMessages.tooLong(3));
});
});
describe('validateOtp', () => {
it('should return null for valid OTP', () => {
expect(validateOtp('123456')).toBeNull();
expect(validateOtp('000000')).toBeNull();
});
it('should return error for empty OTP', () => {
expect(validateOtp('')).toBe(ErrorMessages.required);
expect(validateOtp(' ')).toBe(ErrorMessages.required);
});
it('should return error for invalid OTP', () => {
expect(validateOtp('12345')).toBe(ErrorMessages.invalidOtp); // Too short
expect(validateOtp('1234567')).toBe(ErrorMessages.invalidOtp); // Too long
expect(validateOtp('12345a')).toBe(ErrorMessages.invalidOtp); // Contains letter
expect(validateOtp('12 345')).toBe(ErrorMessages.invalidOtp); // Contains space
});
it('should respect custom length', () => {
expect(validateOtp('1234', 4)).toBeNull();
expect(validateOtp('123456', 4)).toBe(ErrorMessages.invalidOtp);
});
});
describe('formatErrorMessage', () => {
it('should capitalize first letter', () => {
expect(formatErrorMessage('error message')).toBe('Error message.');
});
it('should add period if missing', () => {
expect(formatErrorMessage('Error message')).toBe('Error message.');
});
it('should not add period if already ends with punctuation', () => {
expect(formatErrorMessage('Error message.')).toBe('Error message.');
expect(formatErrorMessage('Error message!')).toBe('Error message!');
expect(formatErrorMessage('Error message?')).toBe('Error message?');
});
it('should return generic error for empty input', () => {
expect(formatErrorMessage('')).toBe(ErrorMessages.genericError);
});
it('should trim whitespace', () => {
expect(formatErrorMessage(' error ')).toBe('Error.');
});
});
describe('combineFieldErrors', () => {
it('should return empty string for no errors', () => {
expect(combineFieldErrors([])).toBe('');
});
it('should return single message for one error', () => {
const errors = [{ field: 'email', message: 'Invalid email' }];
expect(combineFieldErrors(errors)).toBe('Invalid email');
});
it('should combine multiple errors', () => {
const errors = [
{ field: 'email', message: 'Invalid email' },
{ field: 'phone', message: 'Invalid phone' },
];
const result = combineFieldErrors(errors);
expect(result).toContain('2 errors');
expect(result).toContain('Invalid email');
expect(result).toContain('Invalid phone');
});
});
describe('getErrorCountText', () => {
it('should return "No errors" for 0', () => {
expect(getErrorCountText(0)).toBe('No errors');
});
it('should return "1 error" for 1', () => {
expect(getErrorCountText(1)).toBe('1 error');
});
it('should return plural for multiple errors', () => {
expect(getErrorCountText(2)).toBe('2 errors');
expect(getErrorCountText(5)).toBe('5 errors');
});
});
});

View File

@ -0,0 +1,215 @@
/**
* 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;

View File

@ -0,0 +1,184 @@
/**
* FieldError - Inline field validation error display
*
* Features:
* - Animated appearance
* - Icon with error message
* - Compact inline design
* - Accessible labels
*/
import React, { useEffect, useRef } from 'react';
import {
View,
Text,
StyleSheet,
Animated,
AccessibilityInfo,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
interface FieldErrorProps {
message: string;
visible?: boolean;
animated?: boolean;
accessibilityLabel?: string;
}
export function FieldError({
message,
visible = true,
animated = true,
accessibilityLabel,
}: FieldErrorProps) {
const opacityAnim = useRef(new Animated.Value(0)).current;
const translateYAnim = useRef(new Animated.Value(-10)).current;
useEffect(() => {
if (visible) {
if (animated) {
Animated.parallel([
Animated.timing(opacityAnim, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}),
Animated.spring(translateYAnim, {
toValue: 0,
tension: 100,
friction: 10,
useNativeDriver: true,
}),
]).start();
} else {
opacityAnim.setValue(1);
translateYAnim.setValue(0);
}
// Announce error to screen readers
AccessibilityInfo.announceForAccessibility(
accessibilityLabel || `Error: ${message}`
);
} else {
Animated.timing(opacityAnim, {
toValue: 0,
duration: 150,
useNativeDriver: true,
}).start();
}
}, [visible, message, animated, opacityAnim, translateYAnim, accessibilityLabel]);
if (!visible) {
return null;
}
return (
<Animated.View
style={[
styles.container,
{
opacity: opacityAnim,
transform: [{ translateY: translateYAnim }],
},
]}
accessibilityRole="alert"
accessibilityLabel={accessibilityLabel || message}
>
<Ionicons name="alert-circle" size={14} color="#DC2626" style={styles.icon} />
<Text style={styles.message}>{message}</Text>
</Animated.View>
);
}
/**
* FieldErrorSummary - Summary of multiple field errors
*/
interface FieldErrorSummaryProps {
errors: { field: string; message: string }[];
visible?: boolean;
}
export function FieldErrorSummary({ errors, visible = true }: FieldErrorSummaryProps) {
if (!visible || errors.length === 0) {
return null;
}
return (
<View style={styles.summaryContainer} accessibilityRole="alert">
<View style={styles.summaryHeader}>
<Ionicons name="alert-circle" size={18} color="#DC2626" />
<Text style={styles.summaryTitle}>
Please fix the following {errors.length === 1 ? 'error' : 'errors'}:
</Text>
</View>
<View style={styles.summaryList}>
{errors.map((error, index) => (
<View key={`${error.field}-${index}`} style={styles.summaryItem}>
<Text style={styles.summaryBullet}></Text>
<Text style={styles.summaryMessage}>{error.message}</Text>
</View>
))}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 4,
paddingHorizontal: 2,
},
icon: {
marginRight: 4,
},
message: {
fontSize: 12,
color: '#DC2626',
flex: 1,
lineHeight: 16,
},
// Summary styles
summaryContainer: {
backgroundColor: '#FEE2E2',
borderRadius: 8,
padding: 12,
borderWidth: 1,
borderColor: '#FECACA',
marginVertical: 8,
},
summaryHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
},
summaryTitle: {
fontSize: 14,
fontWeight: '600',
color: '#991B1B',
marginLeft: 8,
},
summaryList: {
paddingLeft: 26,
},
summaryItem: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 4,
},
summaryBullet: {
fontSize: 12,
color: '#991B1B',
marginRight: 8,
lineHeight: 16,
},
summaryMessage: {
fontSize: 13,
color: '#991B1B',
flex: 1,
lineHeight: 16,
},
});
export default FieldError;

View File

@ -0,0 +1,272 @@
/**
* FullScreenError - Full screen error state component
*
* Used for:
* - Critical errors that block the UI
* - Data loading failures
* - Network offline states
*/
import React from 'react';
import {
View,
Text,
TouchableOpacity,
StyleSheet,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { AppError, ErrorCategory } from '@/types/errors';
interface FullScreenErrorProps {
error?: AppError;
title?: string;
message?: string;
onRetry?: () => void;
onDismiss?: () => void;
showDismiss?: boolean;
retryLabel?: string;
dismissLabel?: string;
}
// Category-specific icons
const categoryIcons: Record<ErrorCategory, keyof typeof Ionicons.glyphMap> = {
network: 'cloud-offline',
timeout: 'time-outline',
authentication: 'lock-closed',
permission: 'shield-outline',
notFound: 'search-outline',
validation: 'warning',
conflict: 'git-merge',
rateLimit: 'hourglass',
server: 'server-outline',
client: 'alert-circle',
ble: 'bluetooth',
sensor: 'hardware-chip',
subscription: 'card',
unknown: 'help-circle',
};
export function FullScreenError({
error,
title,
message,
onRetry,
onDismiss,
showDismiss = false,
retryLabel = 'Try Again',
dismissLabel = 'Dismiss',
}: FullScreenErrorProps) {
const displayTitle = title || (error?.category === 'network' ? 'Connection Error' : 'Something Went Wrong');
const displayMessage = message || error?.userMessage || 'An unexpected error occurred. Please try again.';
const icon = error?.category ? categoryIcons[error.category] : 'alert-circle';
const isRetryable = error?.retry.isRetryable ?? !!onRetry;
return (
<View style={styles.container}>
<View style={styles.content}>
{/* Icon */}
<View style={styles.iconContainer}>
<Ionicons name={icon} size={64} color="#9CA3AF" />
</View>
{/* Title */}
<Text style={styles.title}>{displayTitle}</Text>
{/* Message */}
<Text style={styles.message}>{displayMessage}</Text>
{/* Action hint */}
{error?.actionHint && (
<Text style={styles.hint}>{error.actionHint}</Text>
)}
{/* Buttons */}
<View style={styles.buttons}>
{isRetryable && onRetry && (
<TouchableOpacity
style={styles.retryButton}
onPress={onRetry}
activeOpacity={0.8}
>
<Ionicons name="refresh" size={20} color="#fff" style={styles.buttonIcon} />
<Text style={styles.retryButtonText}>{retryLabel}</Text>
</TouchableOpacity>
)}
{showDismiss && onDismiss && (
<TouchableOpacity
style={styles.dismissButton}
onPress={onDismiss}
activeOpacity={0.8}
>
<Text style={styles.dismissButtonText}>{dismissLabel}</Text>
</TouchableOpacity>
)}
</View>
{/* Error ID for support */}
{error?.errorId && __DEV__ && (
<Text style={styles.errorId}>Error ID: {error.errorId}</Text>
)}
</View>
</View>
);
}
/**
* EmptyState - Component for empty data states (not strictly an error)
*/
interface EmptyStateProps {
icon?: keyof typeof Ionicons.glyphMap;
title: string;
message?: string;
actionLabel?: string;
onAction?: () => void;
}
export function EmptyState({
icon = 'folder-open-outline',
title,
message,
actionLabel,
onAction,
}: EmptyStateProps) {
return (
<View style={styles.container}>
<View style={styles.content}>
<View style={styles.iconContainer}>
<Ionicons name={icon} size={64} color="#9CA3AF" />
</View>
<Text style={styles.title}>{title}</Text>
{message && <Text style={styles.message}>{message}</Text>}
{actionLabel && onAction && (
<TouchableOpacity
style={styles.retryButton}
onPress={onAction}
activeOpacity={0.8}
>
<Text style={styles.retryButtonText}>{actionLabel}</Text>
</TouchableOpacity>
)}
</View>
</View>
);
}
/**
* OfflineState - Specific state for offline/no connection
*/
interface OfflineStateProps {
onRetry?: () => void;
}
export function OfflineState({ onRetry }: OfflineStateProps) {
return (
<View style={styles.container}>
<View style={styles.content}>
<View style={styles.iconContainer}>
<Ionicons name="cloud-offline" size={64} color="#9CA3AF" />
</View>
<Text style={styles.title}>No Internet Connection</Text>
<Text style={styles.message}>
Please check your connection and try again.
</Text>
{onRetry && (
<TouchableOpacity
style={styles.retryButton}
onPress={onRetry}
activeOpacity={0.8}
>
<Ionicons name="refresh" size={20} color="#fff" style={styles.buttonIcon} />
<Text style={styles.retryButtonText}>Try Again</Text>
</TouchableOpacity>
)}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#fff',
padding: 24,
},
content: {
alignItems: 'center',
maxWidth: 320,
},
iconContainer: {
width: 120,
height: 120,
borderRadius: 60,
backgroundColor: '#F3F4F6',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 24,
},
title: {
fontSize: 20,
fontWeight: '700',
color: '#1F2937',
textAlign: 'center',
marginBottom: 8,
},
message: {
fontSize: 15,
color: '#6B7280',
textAlign: 'center',
lineHeight: 22,
marginBottom: 8,
},
hint: {
fontSize: 13,
color: '#9CA3AF',
textAlign: 'center',
marginBottom: 24,
},
buttons: {
marginTop: 16,
gap: 12,
width: '100%',
},
retryButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#3B82F6',
paddingVertical: 14,
paddingHorizontal: 24,
borderRadius: 12,
width: '100%',
},
buttonIcon: {
marginRight: 8,
},
retryButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
dismissButton: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
paddingHorizontal: 24,
},
dismissButtonText: {
color: '#6B7280',
fontSize: 15,
fontWeight: '500',
},
errorId: {
marginTop: 24,
fontSize: 11,
color: '#D1D5DB',
fontFamily: 'monospace',
},
});
export default FullScreenError;

View File

@ -0,0 +1,7 @@
/**
* Error Components Export
*/
export { ErrorToast } from './ErrorToast';
export { FieldError, FieldErrorSummary } from './FieldError';
export { FullScreenError, EmptyState, OfflineState } from './FullScreenError';

384
contexts/ErrorContext.tsx Normal file
View File

@ -0,0 +1,384 @@
/**
* ErrorContext - Global Error State Management
*
* Provides centralized error handling with:
* - Error queue for multiple errors
* - Toast notifications for transient errors
* - Persistent errors that require user action
* - Error dismissal and retry callbacks
*/
import React, { createContext, useContext, useCallback, useReducer, useMemo, useRef, ReactNode } from 'react';
import { AppError, ErrorSeverity, isAuthError, isCriticalError } from '@/types/errors';
import { logError } from '@/services/errorHandler';
// Maximum number of errors to keep in history
const MAX_ERROR_HISTORY = 50;
// Auto-dismiss timeout for non-critical errors (ms)
const AUTO_DISMISS_TIMEOUT = 5000;
// Error with display metadata
interface DisplayableError extends AppError {
isVisible: boolean;
autoDismiss: boolean;
onRetry?: () => void;
onDismiss?: () => void;
}
// State shape
interface ErrorContextState {
// Current visible error (top of queue)
currentError: DisplayableError | null;
// All pending errors
errorQueue: DisplayableError[];
// Error history (for debugging)
errorHistory: AppError[];
// Global loading state for error recovery
isRecovering: boolean;
}
// Actions
type ErrorAction =
| { type: 'ADD_ERROR'; payload: DisplayableError }
| { type: 'DISMISS_CURRENT' }
| { type: 'DISMISS_ALL' }
| { type: 'DISMISS_BY_ID'; payload: string }
| { type: 'SET_RECOVERING'; payload: boolean }
| { type: 'CLEAR_HISTORY' };
// Initial state
const initialState: ErrorContextState = {
currentError: null,
errorQueue: [],
errorHistory: [],
isRecovering: false,
};
// Reducer
function errorReducer(state: ErrorContextState, action: ErrorAction): ErrorContextState {
switch (action.type) {
case 'ADD_ERROR': {
const newError = action.payload;
// Add to history
const newHistory = [newError, ...state.errorHistory].slice(0, MAX_ERROR_HISTORY);
// If no current error, set this as current
if (!state.currentError) {
return {
...state,
currentError: { ...newError, isVisible: true },
errorHistory: newHistory,
};
}
// Add to queue
return {
...state,
errorQueue: [...state.errorQueue, newError],
errorHistory: newHistory,
};
}
case 'DISMISS_CURRENT': {
// Get next error from queue
const [nextError, ...remainingQueue] = state.errorQueue;
return {
...state,
currentError: nextError ? { ...nextError, isVisible: true } : null,
errorQueue: remainingQueue,
};
}
case 'DISMISS_ALL': {
return {
...state,
currentError: null,
errorQueue: [],
};
}
case 'DISMISS_BY_ID': {
const errorId = action.payload;
// If current error matches, dismiss it
if (state.currentError?.errorId === errorId) {
const [nextError, ...remainingQueue] = state.errorQueue;
return {
...state,
currentError: nextError ? { ...nextError, isVisible: true } : null,
errorQueue: remainingQueue,
};
}
// Remove from queue
return {
...state,
errorQueue: state.errorQueue.filter((e) => e.errorId !== errorId),
};
}
case 'SET_RECOVERING': {
return {
...state,
isRecovering: action.payload,
};
}
case 'CLEAR_HISTORY': {
return {
...state,
errorHistory: [],
};
}
default:
return state;
}
}
// Context value shape
interface ErrorContextValue {
// State
currentError: DisplayableError | null;
errorQueue: DisplayableError[];
errorHistory: AppError[];
isRecovering: boolean;
hasErrors: boolean;
errorCount: number;
// Actions
showError: (
error: AppError,
options?: {
autoDismiss?: boolean;
onRetry?: () => void;
onDismiss?: () => void;
}
) => void;
dismissCurrent: () => void;
dismissAll: () => void;
dismissById: (errorId: string) => void;
clearHistory: () => void;
setRecovering: (recovering: boolean) => void;
// Utilities
getErrorsByCategory: (category: string) => AppError[];
getErrorsBySeverity: (severity: ErrorSeverity) => AppError[];
}
// Create context
const ErrorContext = createContext<ErrorContextValue | undefined>(undefined);
// Provider component
interface ErrorProviderProps {
children: ReactNode;
onAuthError?: (error: AppError) => void;
onCriticalError?: (error: AppError) => void;
}
export function ErrorProvider({
children,
onAuthError,
onCriticalError,
}: ErrorProviderProps) {
const [state, dispatch] = useReducer(errorReducer, initialState);
// Timer refs for auto-dismiss
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Clear existing timer
const clearDismissTimer = useCallback(() => {
if (dismissTimerRef.current) {
clearTimeout(dismissTimerRef.current);
dismissTimerRef.current = null;
}
}, []);
// Schedule auto-dismiss
const scheduleAutoDismiss = useCallback((timeout: number = AUTO_DISMISS_TIMEOUT) => {
clearDismissTimer();
dismissTimerRef.current = setTimeout(() => {
dispatch({ type: 'DISMISS_CURRENT' });
}, timeout);
}, [clearDismissTimer]);
// Show error
const showError = useCallback(
(
error: AppError,
options?: {
autoDismiss?: boolean;
onRetry?: () => void;
onDismiss?: () => void;
}
) => {
// Log error
logError(error);
// Handle auth errors specially
if (isAuthError(error) && onAuthError) {
onAuthError(error);
return;
}
// Handle critical errors specially
if (isCriticalError(error) && onCriticalError) {
onCriticalError(error);
}
// Determine auto-dismiss behavior
const shouldAutoDismiss =
options?.autoDismiss ??
(error.severity !== 'critical' && error.severity !== 'error');
const displayableError: DisplayableError = {
...error,
isVisible: true,
autoDismiss: shouldAutoDismiss,
onRetry: options?.onRetry,
onDismiss: options?.onDismiss,
};
dispatch({ type: 'ADD_ERROR', payload: displayableError });
// Schedule auto-dismiss if applicable
if (shouldAutoDismiss && !state.currentError) {
scheduleAutoDismiss();
}
},
[state.currentError, onAuthError, onCriticalError, scheduleAutoDismiss]
);
// Dismiss current error
const dismissCurrent = useCallback(() => {
clearDismissTimer();
// Call onDismiss callback if provided
state.currentError?.onDismiss?.();
dispatch({ type: 'DISMISS_CURRENT' });
// Schedule auto-dismiss for next error if applicable
const nextError = state.errorQueue[0];
if (nextError?.autoDismiss) {
scheduleAutoDismiss();
}
}, [state.currentError, state.errorQueue, clearDismissTimer, scheduleAutoDismiss]);
// Dismiss all errors
const dismissAll = useCallback(() => {
clearDismissTimer();
dispatch({ type: 'DISMISS_ALL' });
}, [clearDismissTimer]);
// Dismiss by ID
const dismissById = useCallback(
(errorId: string) => {
if (state.currentError?.errorId === errorId) {
dismissCurrent();
} else {
dispatch({ type: 'DISMISS_BY_ID', payload: errorId });
}
},
[state.currentError, dismissCurrent]
);
// Clear history
const clearHistory = useCallback(() => {
dispatch({ type: 'CLEAR_HISTORY' });
}, []);
// Set recovering state
const setRecovering = useCallback((recovering: boolean) => {
dispatch({ type: 'SET_RECOVERING', payload: recovering });
}, []);
// Get errors by category
const getErrorsByCategory = useCallback(
(category: string) => {
return state.errorHistory.filter((e) => e.category === category);
},
[state.errorHistory]
);
// Get errors by severity
const getErrorsBySeverity = useCallback(
(severity: ErrorSeverity) => {
return state.errorHistory.filter((e) => e.severity === severity);
},
[state.errorHistory]
);
// Context value
const value = useMemo<ErrorContextValue>(
() => ({
currentError: state.currentError,
errorQueue: state.errorQueue,
errorHistory: state.errorHistory,
isRecovering: state.isRecovering,
hasErrors: state.currentError !== null || state.errorQueue.length > 0,
errorCount: (state.currentError ? 1 : 0) + state.errorQueue.length,
showError,
dismissCurrent,
dismissAll,
dismissById,
clearHistory,
setRecovering,
getErrorsByCategory,
getErrorsBySeverity,
}),
[
state.currentError,
state.errorQueue,
state.errorHistory,
state.isRecovering,
showError,
dismissCurrent,
dismissAll,
dismissById,
clearHistory,
setRecovering,
getErrorsByCategory,
getErrorsBySeverity,
]
);
return (
<ErrorContext.Provider value={value}>{children}</ErrorContext.Provider>
);
}
// Hook to use error context
export function useErrorContext(): ErrorContextValue {
const context = useContext(ErrorContext);
if (!context) {
throw new Error('useErrorContext must be used within an ErrorProvider');
}
return context;
}
// Convenience hook for showing errors
export function useShowError() {
const { showError } = useErrorContext();
return showError;
}
// Safe hook that returns undefined if not in ErrorProvider (for optional use)
export function useShowErrorSafe() {
const context = useContext(ErrorContext);
return context?.showError;
}
// Convenience hook for current error
export function useCurrentError() {
const { currentError, dismissCurrent } = useErrorContext();
return { error: currentError, dismiss: dismissCurrent };
}
export default ErrorContext;

466
hooks/useAsync.ts Normal file
View File

@ -0,0 +1,466 @@
/**
* useAsync - Enhanced async state management hook
*
* Features:
* - Loading, success, error states
* - Automatic error handling
* - Retry with exponential backoff
* - Optimistic updates
* - Abort/cancel support
* - Stale data indication
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import { AppError } from '@/types/errors';
import { ApiResponse } from '@/types';
import {
createErrorFromApi,
createErrorFromUnknown,
calculateRetryDelay,
logError,
} from '@/services/errorHandler';
// Async operation states
export type AsyncStatus = 'idle' | 'loading' | 'success' | 'error';
// Async state shape
export interface AsyncState<T> {
status: AsyncStatus;
data: T | null;
error: AppError | null;
isLoading: boolean;
isSuccess: boolean;
isError: boolean;
isIdle: boolean;
isStale: boolean;
lastUpdated: Date | null;
}
// Options for useAsync
interface UseAsyncOptions<T> {
// Initial data
initialData?: T | null;
// Run immediately on mount
immediate?: boolean;
// Keep previous data while loading
keepPreviousData?: boolean;
// Stale time in ms (after which data is considered stale)
staleTime?: number;
// Auto-retry on error
autoRetry?: boolean;
// Max retry attempts
maxRetries?: number;
// Callback on success
onSuccess?: (data: T) => void;
// Callback on error
onError?: (error: AppError) => void;
// Callback on settle (success or error)
onSettled?: (data: T | null, error: AppError | null) => void;
}
// Return type
interface UseAsyncReturn<T, Args extends unknown[]> {
// State
state: AsyncState<T>;
data: T | null;
error: AppError | null;
status: AsyncStatus;
isLoading: boolean;
isSuccess: boolean;
isError: boolean;
isIdle: boolean;
isStale: boolean;
// Actions
execute: (...args: Args) => Promise<T | null>;
reset: () => void;
setData: (data: T | ((prev: T | null) => T)) => void;
setError: (error: AppError | null) => void;
retry: () => Promise<T | null>;
cancel: () => void;
}
// Create initial state
function createInitialState<T>(initialData?: T | null): AsyncState<T> {
return {
status: initialData !== undefined && initialData !== null ? 'success' : 'idle',
data: initialData ?? null,
error: null,
isLoading: false,
isSuccess: initialData !== undefined && initialData !== null,
isError: false,
isIdle: initialData === undefined || initialData === null,
isStale: false,
lastUpdated: initialData !== undefined && initialData !== null ? new Date() : null,
};
}
export function useAsync<T, Args extends unknown[] = []>(
asyncFn: (...args: Args) => Promise<ApiResponse<T>>,
options: UseAsyncOptions<T> = {}
): UseAsyncReturn<T, Args> {
const {
initialData,
immediate = false,
keepPreviousData = true,
staleTime,
autoRetry = false,
maxRetries = 3,
onSuccess,
onError,
onSettled,
} = options;
const [state, setState] = useState<AsyncState<T>>(() =>
createInitialState(initialData)
);
// Refs
const mountedRef = useRef(true);
const abortControllerRef = useRef<AbortController | null>(null);
const lastArgsRef = useRef<Args | null>(null);
const retryCountRef = useRef(0);
// Check if data is stale
useEffect(() => {
if (!staleTime || !state.lastUpdated || state.isLoading) {
return;
}
const checkStale = () => {
const now = new Date();
const elapsed = now.getTime() - state.lastUpdated!.getTime();
if (elapsed > staleTime && !state.isStale) {
setState((prev) => ({ ...prev, isStale: true }));
}
};
// Check immediately
checkStale();
// Set up interval
const interval = setInterval(checkStale, Math.min(staleTime / 2, 30000));
return () => clearInterval(interval);
}, [staleTime, state.lastUpdated, state.isLoading, state.isStale]);
// Cleanup on unmount
useEffect(() => {
return () => {
mountedRef.current = false;
abortControllerRef.current?.abort();
};
}, []);
// Execute the async function
const execute = useCallback(
async (...args: Args): Promise<T | null> => {
// Cancel previous request
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
// Store args for retry
lastArgsRef.current = args;
retryCountRef.current = 0;
// Set loading state
setState((prev) => ({
...prev,
status: 'loading',
isLoading: true,
isIdle: false,
error: keepPreviousData ? null : prev.error,
data: keepPreviousData ? prev.data : null,
}));
const executeWithRetry = async (attempt: number): Promise<T | null> => {
try {
const response = await asyncFn(...args);
// Check if aborted or unmounted
if (!mountedRef.current || abortControllerRef.current?.signal.aborted) {
return null;
}
if (response.ok && response.data !== undefined) {
const data = response.data;
setState({
status: 'success',
data,
error: null,
isLoading: false,
isSuccess: true,
isError: false,
isIdle: false,
isStale: false,
lastUpdated: new Date(),
});
onSuccess?.(data);
onSettled?.(data, null);
return data;
}
// Handle error
if (response.error) {
const appError = createErrorFromApi(response.error);
// Check if should retry
if (
autoRetry &&
appError.retry.isRetryable &&
attempt < maxRetries
) {
const delay = calculateRetryDelay(appError.retry, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
if (!mountedRef.current || abortControllerRef.current?.signal.aborted) {
return null;
}
return executeWithRetry(attempt + 1);
}
// Log and set error
logError(appError);
setState({
status: 'error',
data: keepPreviousData ? state.data : null,
error: appError,
isLoading: false,
isSuccess: false,
isError: true,
isIdle: false,
isStale: false,
lastUpdated: state.lastUpdated,
});
onError?.(appError);
onSettled?.(null, appError);
}
return null;
} catch (error) {
// Check if aborted
if (!mountedRef.current || abortControllerRef.current?.signal.aborted) {
return null;
}
const appError = createErrorFromUnknown(error);
// Check if should retry
if (
autoRetry &&
appError.retry.isRetryable &&
attempt < maxRetries
) {
const delay = calculateRetryDelay(appError.retry, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
if (!mountedRef.current || abortControllerRef.current?.signal.aborted) {
return null;
}
return executeWithRetry(attempt + 1);
}
// Log and set error
logError(appError);
setState({
status: 'error',
data: keepPreviousData ? state.data : null,
error: appError,
isLoading: false,
isSuccess: false,
isError: true,
isIdle: false,
isStale: false,
lastUpdated: state.lastUpdated,
});
onError?.(appError);
onSettled?.(null, appError);
return null;
}
};
return executeWithRetry(0);
},
[
asyncFn,
keepPreviousData,
autoRetry,
maxRetries,
onSuccess,
onError,
onSettled,
state.data,
state.lastUpdated,
]
);
// Reset to initial state
const reset = useCallback(() => {
abortControllerRef.current?.abort();
setState(createInitialState(initialData));
}, [initialData]);
// Set data manually
const setData = useCallback((data: T | ((prev: T | null) => T)) => {
setState((prev) => {
const newData = typeof data === 'function' ? (data as (prev: T | null) => T)(prev.data) : data;
return {
...prev,
status: 'success',
data: newData,
error: null,
isLoading: false,
isSuccess: true,
isError: false,
isIdle: false,
isStale: false,
lastUpdated: new Date(),
};
});
}, []);
// Set error manually
const setError = useCallback((error: AppError | null) => {
setState((prev) => ({
...prev,
status: error ? 'error' : 'idle',
error,
isLoading: false,
isSuccess: false,
isError: !!error,
isIdle: !error,
}));
}, []);
// Retry last execution
const retry = useCallback(async (): Promise<T | null> => {
if (!lastArgsRef.current) {
return null;
}
return execute(...lastArgsRef.current);
}, [execute]);
// Cancel current execution
const cancel = useCallback(() => {
abortControllerRef.current?.abort();
setState((prev) => ({
...prev,
isLoading: false,
}));
}, []);
// Execute immediately if specified
useEffect(() => {
if (immediate) {
execute(...([] as unknown as Args));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {
state,
data: state.data,
error: state.error,
status: state.status,
isLoading: state.isLoading,
isSuccess: state.isSuccess,
isError: state.isError,
isIdle: state.isIdle,
isStale: state.isStale,
execute,
reset,
setData,
setError,
retry,
cancel,
};
}
/**
* useAsyncCallback - Similar to useAsync but for one-off operations
* (like form submissions) rather than data fetching
*/
export function useAsyncCallback<T, Args extends unknown[] = []>(
asyncFn: (...args: Args) => Promise<T>,
options: Omit<UseAsyncOptions<T>, 'immediate'> = {}
) {
const { onSuccess, onError, onSettled, autoRetry = false, maxRetries = 3 } = options;
const [state, setState] = useState<{
isLoading: boolean;
error: AppError | null;
}>({
isLoading: false,
error: null,
});
const mountedRef = useRef(true);
useEffect(() => {
return () => {
mountedRef.current = false;
};
}, []);
const execute = useCallback(
async (...args: Args): Promise<T | null> => {
setState({ isLoading: true, error: null });
const executeWithRetry = async (attempt: number): Promise<T | null> => {
try {
const result = await asyncFn(...args);
if (!mountedRef.current) return null;
setState({ isLoading: false, error: null });
onSuccess?.(result);
onSettled?.(result, null);
return result;
} catch (error) {
if (!mountedRef.current) return null;
const appError = createErrorFromUnknown(error);
if (autoRetry && appError.retry.isRetryable && attempt < maxRetries) {
const delay = calculateRetryDelay(appError.retry, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
if (!mountedRef.current) return null;
return executeWithRetry(attempt + 1);
}
logError(appError);
setState({ isLoading: false, error: appError });
onError?.(appError);
onSettled?.(null, appError);
return null;
}
};
return executeWithRetry(0);
},
[asyncFn, autoRetry, maxRetries, onSuccess, onError, onSettled]
);
const reset = useCallback(() => {
setState({ isLoading: false, error: null });
}, []);
return {
execute,
reset,
isLoading: state.isLoading,
error: state.error,
isError: state.error !== null,
};
}
export default useAsync;

368
hooks/useError.ts Normal file
View File

@ -0,0 +1,368 @@
/**
* useError - Hook for error handling with recovery strategies
*
* Features:
* - Handle API errors consistently
* - Automatic retry with exponential backoff
* - User-friendly error display
* - Error state management
*/
import { useState, useCallback, useRef } from 'react';
import { Alert } from 'react-native';
import {
AppError,
ErrorState,
initialErrorState,
isRetryableError,
} from '@/types/errors';
import {
createErrorFromApi,
createErrorFromUnknown,
calculateRetryDelay,
logError,
} from '@/services/errorHandler';
import { ApiError, ApiResponse } from '@/types';
import { useShowErrorSafe } from '@/contexts/ErrorContext';
interface UseErrorOptions {
// Show error in global toast
showToast?: boolean;
// Show native alert
showAlert?: boolean;
// Custom alert title
alertTitle?: string;
// Auto-retry on failure
autoRetry?: boolean;
// Max retry attempts (overrides error default)
maxRetries?: number;
// Callback when error occurs
onError?: (error: AppError) => void;
// Callback when retry starts
onRetryStart?: (attempt: number) => void;
// Callback when all retries exhausted
onRetryExhausted?: (error: AppError) => void;
}
interface UseErrorReturn {
// Current error state
errorState: ErrorState;
// Set error from API response
setApiError: (error: ApiError, context?: Record<string, unknown>) => void;
// Set error from any value
setError: (error: unknown, context?: Record<string, unknown>) => void;
// Clear current error
clearError: () => void;
// Clear all errors
clearAllErrors: () => void;
// Handle API response (returns data if ok, sets error if not)
handleApiResponse: <T>(
response: ApiResponse<T>,
context?: Record<string, unknown>
) => T | null;
// Wrap async function with error handling
wrapAsync: <T>(
fn: () => Promise<T>,
context?: Record<string, unknown>
) => Promise<T | null>;
// Execute with retry
executeWithRetry: <T>(
fn: () => Promise<ApiResponse<T>>,
context?: Record<string, unknown>
) => Promise<T | null>;
// Check if currently has error
hasError: boolean;
// Current error (if any)
error: AppError | null;
}
export function useError(options: UseErrorOptions = {}): UseErrorReturn {
const {
showToast = true,
showAlert = false,
alertTitle = 'Error',
autoRetry = false,
maxRetries,
onError,
onRetryStart,
onRetryExhausted,
} = options;
const [errorState, setErrorState] = useState<ErrorState>(initialErrorState);
const retryCountRef = useRef<Map<string, number>>(new Map());
// Get global show error function (may not be available if not in ErrorProvider)
const showGlobalError = useShowErrorSafe();
// Internal function to handle error
const handleError = useCallback(
(appError: AppError, retryFn?: () => void) => {
// Log error
logError(appError);
// Update state
setErrorState({
hasError: true,
error: appError,
errors: [appError],
lastErrorAt: new Date(),
});
// Call callback
onError?.(appError);
// Show toast
if (showToast && showGlobalError) {
showGlobalError(appError, { onRetry: retryFn });
}
// Show alert
if (showAlert) {
const buttons: { text: string; onPress?: () => void; style?: 'cancel' | 'default' | 'destructive' }[] = [
{ text: 'OK', style: 'cancel' as const },
];
if (isRetryableError(appError) && retryFn) {
buttons.unshift({
text: 'Retry',
onPress: retryFn,
});
}
Alert.alert(alertTitle, appError.userMessage, buttons);
}
},
[showToast, showAlert, alertTitle, onError, showGlobalError]
);
// Set error from API error
const setApiError = useCallback(
(error: ApiError, context?: Record<string, unknown>) => {
const appError = createErrorFromApi(error, context);
handleError(appError);
},
[handleError]
);
// Set error from any value
const setError = useCallback(
(error: unknown, context?: Record<string, unknown>) => {
const appError = createErrorFromUnknown(error, undefined, context);
handleError(appError);
},
[handleError]
);
// Clear current error
const clearError = useCallback(() => {
setErrorState(initialErrorState);
}, []);
// Clear all errors
const clearAllErrors = useCallback(() => {
setErrorState(initialErrorState);
retryCountRef.current.clear();
}, []);
// Handle API response
const handleApiResponse = useCallback(
<T>(response: ApiResponse<T>, context?: Record<string, unknown>): T | null => {
if (response.ok && response.data !== undefined) {
clearError();
return response.data;
}
if (response.error) {
setApiError(response.error, context);
}
return null;
},
[clearError, setApiError]
);
// Wrap async function with error handling
const wrapAsync = useCallback(
async <T>(
fn: () => Promise<T>,
context?: Record<string, unknown>
): Promise<T | null> => {
try {
const result = await fn();
clearError();
return result;
} catch (error) {
setError(error, context);
return null;
}
},
[clearError, setError]
);
// Execute with retry
const executeWithRetry = useCallback(
async <T>(
fn: () => Promise<ApiResponse<T>>,
context?: Record<string, unknown>
): Promise<T | null> => {
const operationId = Date.now().toString();
let attempt = 0;
const maxAttempts = maxRetries ?? 3;
const execute = async (): Promise<T | null> => {
try {
const response = await fn();
if (response.ok && response.data !== undefined) {
clearError();
retryCountRef.current.delete(operationId);
return response.data;
}
if (response.error) {
const appError = createErrorFromApi(response.error, context);
// Check if should retry
const shouldRetry =
autoRetry &&
isRetryableError(appError) &&
attempt < maxAttempts;
if (shouldRetry) {
attempt++;
onRetryStart?.(attempt);
const delay = calculateRetryDelay(appError.retry, attempt - 1);
await new Promise((resolve) => setTimeout(resolve, delay));
return execute();
}
// No more retries
if (attempt > 0) {
onRetryExhausted?.(appError);
}
handleError(appError, autoRetry ? () => execute() : undefined);
}
return null;
} catch (error) {
const appError = createErrorFromUnknown(error, undefined, context);
// Check if should retry
const shouldRetry =
autoRetry &&
isRetryableError(appError) &&
attempt < maxAttempts;
if (shouldRetry) {
attempt++;
onRetryStart?.(attempt);
const delay = calculateRetryDelay(appError.retry, attempt - 1);
await new Promise((resolve) => setTimeout(resolve, delay));
return execute();
}
// No more retries
if (attempt > 0) {
onRetryExhausted?.(appError);
}
handleError(appError, autoRetry ? () => execute() : undefined);
return null;
}
};
return execute();
},
[autoRetry, maxRetries, clearError, handleError, onRetryStart, onRetryExhausted]
);
return {
errorState,
setApiError,
setError,
clearError,
clearAllErrors,
handleApiResponse,
wrapAsync,
executeWithRetry,
hasError: errorState.hasError,
error: errorState.error,
};
}
/**
* useFieldErrors - Hook for managing field-level validation errors
*/
interface FieldErrors {
[field: string]: string | undefined;
}
interface UseFieldErrorsReturn {
errors: FieldErrors;
setFieldError: (field: string, message: string) => void;
clearFieldError: (field: string) => void;
clearAllFieldErrors: () => void;
hasErrors: boolean;
getError: (field: string) => string | undefined;
getErrorList: () => { field: string; message: string }[];
setErrors: (errors: FieldErrors) => void;
}
export function useFieldErrors(): UseFieldErrorsReturn {
const [errors, setErrors] = useState<FieldErrors>({});
const setFieldError = useCallback((field: string, message: string) => {
setErrors((prev) => ({ ...prev, [field]: message }));
}, []);
const clearFieldError = useCallback((field: string) => {
setErrors((prev) => {
const { [field]: _, ...rest } = prev;
return rest;
});
}, []);
const clearAllFieldErrors = useCallback(() => {
setErrors({});
}, []);
const getError = useCallback(
(field: string) => errors[field],
[errors]
);
const getErrorList = useCallback(() => {
return Object.entries(errors)
.filter(([_, message]) => message !== undefined)
.map(([field, message]) => ({ field, message: message as string }));
}, [errors]);
const hasErrors = Object.values(errors).some((v) => v !== undefined);
return {
errors,
setFieldError,
clearFieldError,
clearAllFieldErrors,
hasErrors,
getError,
getErrorList,
setErrors,
};
}
export default useError;

378
services/errorHandler.ts Normal file
View File

@ -0,0 +1,378 @@
/**
* Centralized Error Handler Service
*
* Provides error classification, user-friendly message translation,
* and recovery strategy recommendations.
*/
import {
AppError,
ErrorCategory,
ErrorCode,
ErrorCodes,
ErrorSeverity,
FieldError,
RetryPolicy,
DefaultRetryPolicies,
} from '@/types/errors';
import { ApiError } from '@/types';
// Generate unique error ID
function generateErrorId(): string {
return `err_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
// User-friendly messages for error codes
const userMessages: Record<string, string> = {
// Network
[ErrorCodes.NETWORK_ERROR]: 'Unable to connect. Please check your internet connection.',
[ErrorCodes.NETWORK_TIMEOUT]: 'The request timed out. Please try again.',
[ErrorCodes.NETWORK_OFFLINE]: 'You appear to be offline. Please check your connection.',
// Authentication
[ErrorCodes.UNAUTHORIZED]: 'Your session has expired. Please log in again.',
[ErrorCodes.TOKEN_EXPIRED]: 'Your session has expired. Please log in again.',
[ErrorCodes.INVALID_CREDENTIALS]: 'Invalid email or password.',
[ErrorCodes.SESSION_EXPIRED]: 'Your session has expired. Please log in again.',
// Permission
[ErrorCodes.FORBIDDEN]: 'You don\'t have permission to perform this action.',
[ErrorCodes.INSUFFICIENT_PERMISSIONS]: 'You don\'t have sufficient permissions.',
// Resource
[ErrorCodes.NOT_FOUND]: 'The requested resource was not found.',
[ErrorCodes.BENEFICIARY_NOT_FOUND]: 'Beneficiary not found.',
[ErrorCodes.DEPLOYMENT_NOT_FOUND]: 'Deployment not found.',
[ErrorCodes.USER_NOT_FOUND]: 'User not found.',
// Conflict
[ErrorCodes.CONFLICT]: 'A conflict occurred. Please refresh and try again.',
[ErrorCodes.DUPLICATE_ENTRY]: 'This entry already exists.',
[ErrorCodes.VERSION_MISMATCH]: 'Data has changed. Please refresh and try again.',
// Validation
[ErrorCodes.VALIDATION_ERROR]: 'Please check your input and try again.',
[ErrorCodes.INVALID_INPUT]: 'Invalid input. Please check and try again.',
[ErrorCodes.MISSING_REQUIRED_FIELD]: 'Please fill in all required fields.',
[ErrorCodes.INVALID_EMAIL]: 'Please enter a valid email address.',
[ErrorCodes.INVALID_PHONE]: 'Please enter a valid phone number.',
[ErrorCodes.INVALID_OTP]: 'Invalid verification code. Please try again.',
// Server
[ErrorCodes.SERVER_ERROR]: 'Something went wrong on our end. Please try again later.',
[ErrorCodes.SERVICE_UNAVAILABLE]: 'Service is temporarily unavailable. Please try again later.',
[ErrorCodes.MAINTENANCE]: 'We\'re currently performing maintenance. Please try again soon.',
// Rate limiting
[ErrorCodes.RATE_LIMITED]: 'Too many requests. Please wait a moment and try again.',
[ErrorCodes.TOO_MANY_REQUESTS]: 'Too many requests. Please wait a moment and try again.',
// BLE
[ErrorCodes.BLE_NOT_AVAILABLE]: 'Bluetooth is not available on this device.',
[ErrorCodes.BLE_NOT_ENABLED]: 'Please enable Bluetooth to continue.',
[ErrorCodes.BLE_PERMISSION_DENIED]: 'Bluetooth permission is required.',
[ErrorCodes.BLE_CONNECTION_FAILED]: 'Failed to connect to device. Please try again.',
[ErrorCodes.BLE_DEVICE_NOT_FOUND]: 'Device not found. Make sure it\'s powered on.',
[ErrorCodes.BLE_TIMEOUT]: 'Connection timed out. Please try again.',
// Sensor
[ErrorCodes.SENSOR_SETUP_FAILED]: 'Sensor setup failed. Please try again.',
[ErrorCodes.SENSOR_OFFLINE]: 'Sensor is offline. Check the device connection.',
[ErrorCodes.SENSOR_NOT_RESPONDING]: 'Sensor is not responding. Please try again.',
[ErrorCodes.SENSOR_WIFI_FAILED]: 'Failed to configure WiFi. Please check credentials.',
// Subscription
[ErrorCodes.SUBSCRIPTION_REQUIRED]: 'A subscription is required to access this feature.',
[ErrorCodes.SUBSCRIPTION_EXPIRED]: 'Your subscription has expired.',
[ErrorCodes.PAYMENT_FAILED]: 'Payment failed. Please try again or use a different method.',
[ErrorCodes.PAYMENT_CANCELED]: 'Payment was canceled.',
// General
[ErrorCodes.UNKNOWN_ERROR]: 'Something went wrong. Please try again.',
[ErrorCodes.API_ERROR]: 'An error occurred. Please try again.',
[ErrorCodes.PARSE_ERROR]: 'Failed to process response. Please try again.',
[ErrorCodes.EXCEPTION]: 'An unexpected error occurred.',
[ErrorCodes.NOT_AUTHENTICATED]: 'Please log in to continue.',
[ErrorCodes.NO_BENEFICIARY_SELECTED]: 'Please select a beneficiary.',
[ErrorCodes.NO_DEPLOYMENT]: 'No deployment configured.',
};
// Action hints for error codes
const actionHints: Record<string, string> = {
[ErrorCodes.NETWORK_ERROR]: 'Check your Wi-Fi or cellular connection',
[ErrorCodes.NETWORK_OFFLINE]: 'Connect to the internet to continue',
[ErrorCodes.UNAUTHORIZED]: 'Tap here to log in again',
[ErrorCodes.TOKEN_EXPIRED]: 'Tap here to log in again',
[ErrorCodes.BLE_NOT_ENABLED]: 'Enable Bluetooth in Settings',
[ErrorCodes.BLE_PERMISSION_DENIED]: 'Grant Bluetooth permission in Settings',
[ErrorCodes.SUBSCRIPTION_REQUIRED]: 'View subscription options',
[ErrorCodes.SUBSCRIPTION_EXPIRED]: 'Renew your subscription',
[ErrorCodes.PAYMENT_FAILED]: 'Try a different payment method',
};
// Classify HTTP status to error category
function classifyHttpStatus(status: number): ErrorCategory {
if (status === 401) return 'authentication';
if (status === 403) return 'permission';
if (status === 404) return 'notFound';
if (status === 409) return 'conflict';
if (status === 429) return 'rateLimit';
if (status === 422 || status === 400) return 'validation';
if (status >= 500) return 'server';
if (status >= 400) return 'client';
return 'unknown';
}
// Classify error code to category
function classifyErrorCode(code: string): ErrorCategory {
// Check timeout first before network (NETWORK_TIMEOUT should be timeout, not network)
if (code.includes('TIMEOUT')) return 'timeout';
if (code.startsWith('NETWORK') || code === 'NETWORK_ERROR') return 'network';
if (code.includes('AUTH') || code.includes('TOKEN') || code.includes('SESSION') || code === 'UNAUTHORIZED') {
return 'authentication';
}
if (code.includes('PERMISSION') || code === 'FORBIDDEN') return 'permission';
if (code.includes('NOT_FOUND')) return 'notFound';
if (code.includes('CONFLICT') || code.includes('DUPLICATE') || code.includes('MISMATCH')) return 'conflict';
if (code.includes('VALIDATION') || code.includes('INVALID') || code.includes('MISSING')) return 'validation';
if (code.includes('RATE') || code.includes('TOO_MANY')) return 'rateLimit';
if (code.includes('SERVER') || code.includes('UNAVAILABLE') || code.includes('MAINTENANCE')) return 'server';
if (code.startsWith('BLE')) return 'ble';
if (code.startsWith('SENSOR')) return 'sensor';
if (code.includes('SUBSCRIPTION') || code.includes('PAYMENT')) return 'subscription';
return 'unknown';
}
// Determine severity from category and status
function determineSeverity(category: ErrorCategory, status?: number): ErrorSeverity {
// Critical - app cannot continue
if (category === 'authentication') return 'error';
if (status && status >= 500) return 'error';
if (category === 'server') return 'error';
// Warning - recoverable
if (category === 'network' || category === 'timeout') return 'warning';
if (category === 'rateLimit') return 'warning';
// Error - operation failed
if (category === 'validation' || category === 'permission' || category === 'notFound') return 'error';
// Default to error
return 'error';
}
// Get user message for error
function getUserMessage(code: string, message?: string): string {
// Use predefined message if available
if (userMessages[code]) {
return userMessages[code];
}
// Fall back to provided message or generic
return message || 'Something went wrong. Please try again.';
}
// Get action hint for error
function getActionHint(code: string): string | undefined {
return actionHints[code];
}
// Get retry policy for category
function getRetryPolicy(category: ErrorCategory): RetryPolicy {
return DefaultRetryPolicies[category] || DefaultRetryPolicies.unknown;
}
/**
* Create AppError from API error
*/
export function createErrorFromApi(apiError: ApiError, context?: Record<string, unknown>): AppError {
const code = apiError.code || ErrorCodes.API_ERROR;
const status = apiError.status;
const category = status ? classifyHttpStatus(status) : classifyErrorCode(code);
const severity = determineSeverity(category, status);
return {
message: apiError.message,
code,
severity,
category,
status,
retry: getRetryPolicy(category),
userMessage: getUserMessage(code, apiError.message),
actionHint: getActionHint(code),
context,
timestamp: new Date(),
errorId: generateErrorId(),
};
}
/**
* Create AppError from JavaScript Error
*/
export function createErrorFromException(
error: Error,
code: string = ErrorCodes.EXCEPTION,
context?: Record<string, unknown>
): AppError {
const category = classifyErrorCode(code);
const severity = determineSeverity(category);
return {
message: error.message,
code,
severity,
category,
retry: getRetryPolicy(category),
userMessage: getUserMessage(code, error.message),
actionHint: getActionHint(code),
context,
timestamp: new Date(),
errorId: generateErrorId(),
originalError: error,
};
}
/**
* Create AppError from network error
*/
export function createNetworkError(
message: string = 'Network request failed',
isTimeout: boolean = false
): AppError {
const code = isTimeout ? ErrorCodes.NETWORK_TIMEOUT : ErrorCodes.NETWORK_ERROR;
const category: ErrorCategory = isTimeout ? 'timeout' : 'network';
return {
message,
code,
severity: 'warning',
category,
retry: getRetryPolicy(category),
userMessage: getUserMessage(code),
actionHint: getActionHint(code),
timestamp: new Date(),
errorId: generateErrorId(),
};
}
/**
* Create AppError for validation with field errors
*/
export function createValidationError(
message: string,
fieldErrors: FieldError[] = []
): AppError {
return {
message,
code: ErrorCodes.VALIDATION_ERROR,
severity: 'error',
category: 'validation',
retry: { isRetryable: false },
userMessage: getUserMessage(ErrorCodes.VALIDATION_ERROR, message),
fieldErrors,
timestamp: new Date(),
errorId: generateErrorId(),
};
}
/**
* Create AppError from any unknown error
*/
export function createErrorFromUnknown(
error: unknown,
defaultCode: string = ErrorCodes.UNKNOWN_ERROR,
context?: Record<string, unknown>
): AppError {
// Already an AppError
if (error && typeof error === 'object' && 'errorId' in error) {
return error as AppError;
}
// JavaScript Error
if (error instanceof Error) {
return createErrorFromException(error, defaultCode, context);
}
// ApiError-like object
if (error && typeof error === 'object' && 'message' in error) {
return createErrorFromApi(error as ApiError, context);
}
// String error
if (typeof error === 'string') {
return createErrorFromException(new Error(error), defaultCode, context);
}
// Unknown
return {
message: 'An unknown error occurred',
code: defaultCode,
severity: 'error',
category: 'unknown',
retry: { isRetryable: false },
userMessage: getUserMessage(defaultCode),
timestamp: new Date(),
errorId: generateErrorId(),
originalError: error,
context,
};
}
/**
* Create a simple error with predefined code
*/
export function createError(
code: ErrorCode,
message?: string,
context?: Record<string, unknown>
): AppError {
const category = classifyErrorCode(code);
const severity = determineSeverity(category);
return {
message: message || getUserMessage(code),
code,
severity,
category,
retry: getRetryPolicy(category),
userMessage: getUserMessage(code, message),
actionHint: getActionHint(code),
context,
timestamp: new Date(),
errorId: generateErrorId(),
};
}
/**
* Calculate retry delay with optional exponential backoff
*/
export function calculateRetryDelay(policy: RetryPolicy, attempt: number): number {
if (!policy.retryDelayMs) return 1000;
if (policy.useExponentialBackoff) {
// Exponential backoff: delay * 2^attempt with jitter
const baseDelay = policy.retryDelayMs * Math.pow(2, attempt);
const jitter = Math.random() * 0.3 * baseDelay; // 0-30% jitter
return Math.min(baseDelay + jitter, 30000); // Max 30 seconds
}
return policy.retryDelayMs;
}
/**
* Log error for debugging/analytics (placeholder for future integration)
*/
export function logError(error: AppError): void {
if (__DEV__) {
console.error(`[${error.severity.toUpperCase()}] ${error.code}:`, error.message, {
category: error.category,
errorId: error.errorId,
context: error.context,
});
}
// TODO: Integrate with error tracking service (e.g., Sentry, Bugsnag)
}
// Export error codes for convenience
export { ErrorCodes };

268
types/errors.ts Normal file
View File

@ -0,0 +1,268 @@
/**
* Comprehensive Error Types for WellNuo
*
* Provides extended error handling with severity levels, retry policies,
* contextual information, and user-friendly messaging.
*/
// Error severity levels
export type ErrorSeverity =
| 'critical' // App cannot continue, requires immediate attention
| 'error' // Operation failed, but app can continue
| 'warning' // Something unexpected happened, but recovered
| 'info'; // Informational message about an issue
// Error categories for grouping and handling
export type ErrorCategory =
| 'network' // Network connectivity issues
| 'authentication' // Auth failures (401, 403, token expired)
| 'validation' // Input validation errors
| 'server' // Server-side errors (5xx)
| 'client' // Client-side errors (4xx except auth)
| 'timeout' // Request timeout
| 'permission' // Insufficient permissions (403)
| 'notFound' // Resource not found (404)
| 'conflict' // Resource conflict (409)
| 'rateLimit' // Too many requests (429)
| 'ble' // Bluetooth errors
| 'sensor' // Sensor-related errors
| 'subscription' // Subscription/payment errors
| 'unknown'; // Unclassified errors
// Retry policy for recoverable errors
export interface RetryPolicy {
isRetryable: boolean;
maxRetries?: number;
retryDelayMs?: number;
useExponentialBackoff?: boolean;
}
// Field-level validation error
export interface FieldError {
field: string;
message: string;
code?: string;
}
// Extended error with full context
export interface AppError {
// Core error info
message: string;
code: string;
// Classification
severity: ErrorSeverity;
category: ErrorCategory;
// HTTP context (if from API)
status?: number;
statusText?: string;
// Retry info
retry: RetryPolicy;
// User-facing message (translated, friendly)
userMessage: string;
// Action hint for user
actionHint?: string;
// Additional context
context?: Record<string, unknown>;
// Field-level validation errors
fieldErrors?: FieldError[];
// Timestamp
timestamp: Date;
// Unique error ID for tracking
errorId: string;
// Original error for debugging
originalError?: Error | unknown;
}
// Error codes used throughout the app
export const ErrorCodes = {
// Network errors
NETWORK_ERROR: 'NETWORK_ERROR',
NETWORK_TIMEOUT: 'NETWORK_TIMEOUT',
NETWORK_OFFLINE: 'NETWORK_OFFLINE',
// Authentication errors
UNAUTHORIZED: 'UNAUTHORIZED',
TOKEN_EXPIRED: 'TOKEN_EXPIRED',
INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
SESSION_EXPIRED: 'SESSION_EXPIRED',
// Permission errors
FORBIDDEN: 'FORBIDDEN',
INSUFFICIENT_PERMISSIONS: 'INSUFFICIENT_PERMISSIONS',
// Resource errors
NOT_FOUND: 'NOT_FOUND',
BENEFICIARY_NOT_FOUND: 'BENEFICIARY_NOT_FOUND',
DEPLOYMENT_NOT_FOUND: 'DEPLOYMENT_NOT_FOUND',
USER_NOT_FOUND: 'USER_NOT_FOUND',
// Conflict errors
CONFLICT: 'CONFLICT',
DUPLICATE_ENTRY: 'DUPLICATE_ENTRY',
VERSION_MISMATCH: 'VERSION_MISMATCH',
// Validation errors
VALIDATION_ERROR: 'VALIDATION_ERROR',
INVALID_INPUT: 'INVALID_INPUT',
MISSING_REQUIRED_FIELD: 'MISSING_REQUIRED_FIELD',
INVALID_EMAIL: 'INVALID_EMAIL',
INVALID_PHONE: 'INVALID_PHONE',
INVALID_OTP: 'INVALID_OTP',
// Server errors
SERVER_ERROR: 'SERVER_ERROR',
SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
MAINTENANCE: 'MAINTENANCE',
// Rate limiting
RATE_LIMITED: 'RATE_LIMITED',
TOO_MANY_REQUESTS: 'TOO_MANY_REQUESTS',
// BLE errors
BLE_NOT_AVAILABLE: 'BLE_NOT_AVAILABLE',
BLE_NOT_ENABLED: 'BLE_NOT_ENABLED',
BLE_PERMISSION_DENIED: 'BLE_PERMISSION_DENIED',
BLE_CONNECTION_FAILED: 'BLE_CONNECTION_FAILED',
BLE_DEVICE_NOT_FOUND: 'BLE_DEVICE_NOT_FOUND',
BLE_TIMEOUT: 'BLE_TIMEOUT',
// Sensor errors
SENSOR_SETUP_FAILED: 'SENSOR_SETUP_FAILED',
SENSOR_OFFLINE: 'SENSOR_OFFLINE',
SENSOR_NOT_RESPONDING: 'SENSOR_NOT_RESPONDING',
SENSOR_WIFI_FAILED: 'SENSOR_WIFI_FAILED',
// Subscription errors
SUBSCRIPTION_REQUIRED: 'SUBSCRIPTION_REQUIRED',
SUBSCRIPTION_EXPIRED: 'SUBSCRIPTION_EXPIRED',
PAYMENT_FAILED: 'PAYMENT_FAILED',
PAYMENT_CANCELED: 'PAYMENT_CANCELED',
// General errors
UNKNOWN_ERROR: 'UNKNOWN_ERROR',
API_ERROR: 'API_ERROR',
PARSE_ERROR: 'PARSE_ERROR',
EXCEPTION: 'EXCEPTION',
NOT_AUTHENTICATED: 'NOT_AUTHENTICATED',
NO_BENEFICIARY_SELECTED: 'NO_BENEFICIARY_SELECTED',
NO_DEPLOYMENT: 'NO_DEPLOYMENT',
} as const;
export type ErrorCode = typeof ErrorCodes[keyof typeof ErrorCodes];
// Default retry policies per category
export const DefaultRetryPolicies: Record<ErrorCategory, RetryPolicy> = {
network: {
isRetryable: true,
maxRetries: 3,
retryDelayMs: 1000,
useExponentialBackoff: true,
},
timeout: {
isRetryable: true,
maxRetries: 2,
retryDelayMs: 2000,
useExponentialBackoff: true,
},
server: {
isRetryable: true,
maxRetries: 2,
retryDelayMs: 3000,
useExponentialBackoff: true,
},
rateLimit: {
isRetryable: true,
maxRetries: 1,
retryDelayMs: 30000, // Wait 30 seconds
useExponentialBackoff: false,
},
authentication: {
isRetryable: false,
},
validation: {
isRetryable: false,
},
permission: {
isRetryable: false,
},
notFound: {
isRetryable: false,
},
conflict: {
isRetryable: false,
},
client: {
isRetryable: false,
},
ble: {
isRetryable: true,
maxRetries: 2,
retryDelayMs: 1000,
useExponentialBackoff: false,
},
sensor: {
isRetryable: true,
maxRetries: 2,
retryDelayMs: 2000,
useExponentialBackoff: true,
},
subscription: {
isRetryable: false,
},
unknown: {
isRetryable: false,
},
};
// Error state for async operations
export interface ErrorState {
hasError: boolean;
error: AppError | null;
errors: AppError[]; // For multiple errors
lastErrorAt: Date | null;
}
// Initial error state
export const initialErrorState: ErrorState = {
hasError: false,
error: null,
errors: [],
lastErrorAt: null,
};
// Type guard to check if value is AppError
export function isAppError(error: unknown): error is AppError {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
'severity' in error &&
'category' in error &&
'userMessage' in error
);
}
// Helper to check if error is retryable
export function isRetryableError(error: AppError): boolean {
return error.retry.isRetryable;
}
// Helper to check if error is critical
export function isCriticalError(error: AppError): boolean {
return error.severity === 'critical';
}
// Helper to check if error requires auth
export function isAuthError(error: AppError): boolean {
return error.category === 'authentication';
}

238
utils/errorMessages.ts Normal file
View File

@ -0,0 +1,238 @@
/**
* Error Message Utilities
*
* Provides user-friendly error message translation and formatting.
* Includes helpers for common validation scenarios.
*/
import { ErrorCodes } from '@/types/errors';
// Generic error messages for common scenarios
export const ErrorMessages = {
// Network errors
networkOffline: 'Unable to connect. Please check your internet connection.',
networkTimeout: 'The request took too long. Please try again.',
networkError: 'Connection failed. Please check your network.',
// Auth errors
sessionExpired: 'Your session has expired. Please log in again.',
unauthorized: 'You need to log in to continue.',
forbidden: 'You don\'t have permission to do this.',
// Validation errors
required: 'This field is required.',
invalidEmail: 'Please enter a valid email address.',
invalidPhone: 'Please enter a valid phone number.',
invalidOtp: 'Invalid code. Please try again.',
tooShort: (min: number) => `Must be at least ${min} characters.`,
tooLong: (max: number) => `Must be ${max} characters or less.`,
passwordMismatch: 'Passwords do not match.',
// Generic errors
genericError: 'Something went wrong. Please try again.',
tryAgainLater: 'Something went wrong. Please try again later.',
notFound: 'The item you\'re looking for could not be found.',
// Form errors
formInvalid: 'Please check the form for errors.',
fileTooBig: (maxMb: number) => `File is too large. Maximum size is ${maxMb}MB.`,
invalidFileType: 'This file type is not supported.',
// Subscription errors
subscriptionRequired: 'A subscription is required to use this feature.',
subscriptionExpired: 'Your subscription has expired.',
paymentFailed: 'Payment could not be processed. Please try again.',
// BLE errors
bluetoothDisabled: 'Please enable Bluetooth to continue.',
bluetoothPermissionDenied: 'Bluetooth permission is required.',
deviceNotFound: 'Device not found. Make sure it\'s powered on.',
// Sensor errors
sensorSetupFailed: 'Sensor setup failed. Please try again.',
sensorOffline: 'Sensor is offline.',
wifiConfigFailed: 'Failed to configure WiFi. Check your credentials.',
} as const;
// Action hints for guiding users
export const ActionHints = {
checkConnection: 'Check your Wi-Fi or cellular connection',
enableBluetooth: 'Enable Bluetooth in Settings',
loginAgain: 'Tap here to log in again',
contactSupport: 'Contact support if the problem persists',
tryDifferentPayment: 'Try a different payment method',
refreshPage: 'Pull down to refresh',
checkCredentials: 'Double-check your email and password',
} as const;
/**
* Get a user-friendly message for an error code
*/
export function getErrorMessage(code: string, fallback?: string): string {
const messages: Record<string, string> = {
[ErrorCodes.NETWORK_ERROR]: ErrorMessages.networkError,
[ErrorCodes.NETWORK_TIMEOUT]: ErrorMessages.networkTimeout,
[ErrorCodes.NETWORK_OFFLINE]: ErrorMessages.networkOffline,
[ErrorCodes.UNAUTHORIZED]: ErrorMessages.sessionExpired,
[ErrorCodes.TOKEN_EXPIRED]: ErrorMessages.sessionExpired,
[ErrorCodes.SESSION_EXPIRED]: ErrorMessages.sessionExpired,
[ErrorCodes.FORBIDDEN]: ErrorMessages.forbidden,
[ErrorCodes.NOT_FOUND]: ErrorMessages.notFound,
[ErrorCodes.VALIDATION_ERROR]: ErrorMessages.formInvalid,
[ErrorCodes.INVALID_EMAIL]: ErrorMessages.invalidEmail,
[ErrorCodes.INVALID_PHONE]: ErrorMessages.invalidPhone,
[ErrorCodes.INVALID_OTP]: ErrorMessages.invalidOtp,
[ErrorCodes.SUBSCRIPTION_REQUIRED]: ErrorMessages.subscriptionRequired,
[ErrorCodes.SUBSCRIPTION_EXPIRED]: ErrorMessages.subscriptionExpired,
[ErrorCodes.PAYMENT_FAILED]: ErrorMessages.paymentFailed,
[ErrorCodes.BLE_NOT_ENABLED]: ErrorMessages.bluetoothDisabled,
[ErrorCodes.BLE_PERMISSION_DENIED]: ErrorMessages.bluetoothPermissionDenied,
[ErrorCodes.BLE_DEVICE_NOT_FOUND]: ErrorMessages.deviceNotFound,
[ErrorCodes.SENSOR_SETUP_FAILED]: ErrorMessages.sensorSetupFailed,
[ErrorCodes.SENSOR_OFFLINE]: ErrorMessages.sensorOffline,
[ErrorCodes.SENSOR_WIFI_FAILED]: ErrorMessages.wifiConfigFailed,
};
return messages[code] || fallback || ErrorMessages.genericError;
}
/**
* Get an action hint for an error code
*/
export function getActionHint(code: string): string | undefined {
const hints: Record<string, string> = {
[ErrorCodes.NETWORK_ERROR]: ActionHints.checkConnection,
[ErrorCodes.NETWORK_OFFLINE]: ActionHints.checkConnection,
[ErrorCodes.UNAUTHORIZED]: ActionHints.loginAgain,
[ErrorCodes.TOKEN_EXPIRED]: ActionHints.loginAgain,
[ErrorCodes.BLE_NOT_ENABLED]: ActionHints.enableBluetooth,
[ErrorCodes.BLE_PERMISSION_DENIED]: ActionHints.enableBluetooth,
[ErrorCodes.PAYMENT_FAILED]: ActionHints.tryDifferentPayment,
};
return hints[code];
}
// Validation helpers
/**
* Validate email format
*/
export function validateEmail(email: string): string | null {
if (!email || email.trim() === '') {
return ErrorMessages.required;
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email.trim())) {
return ErrorMessages.invalidEmail;
}
return null;
}
/**
* Validate phone number format
*/
export function validatePhone(phone: string): string | null {
if (!phone || phone.trim() === '') {
return null; // Phone is usually optional
}
// Remove common formatting characters
const cleaned = phone.replace(/[\s\-\(\)\.]/g, '');
// Check for valid phone format (with or without country code)
const phoneRegex = /^\+?[1-9]\d{9,14}$/;
if (!phoneRegex.test(cleaned)) {
return ErrorMessages.invalidPhone;
}
return null;
}
/**
* Validate required field
*/
export function validateRequired(value: string | undefined | null, fieldName?: string): string | null {
if (!value || value.trim() === '') {
return fieldName ? `${fieldName} is required.` : ErrorMessages.required;
}
return null;
}
/**
* Validate minimum length
*/
export function validateMinLength(value: string, minLength: number): string | null {
if (value.length < minLength) {
return ErrorMessages.tooShort(minLength);
}
return null;
}
/**
* Validate maximum length
*/
export function validateMaxLength(value: string, maxLength: number): string | null {
if (value.length > maxLength) {
return ErrorMessages.tooLong(maxLength);
}
return null;
}
/**
* Validate OTP code format
*/
export function validateOtp(otp: string, length: number = 6): string | null {
if (!otp || otp.trim() === '') {
return ErrorMessages.required;
}
const cleaned = otp.trim();
if (cleaned.length !== length || !/^\d+$/.test(cleaned)) {
return ErrorMessages.invalidOtp;
}
return null;
}
/**
* Format error for display (capitalize first letter, add period if missing)
*/
export function formatErrorMessage(message: string): string {
if (!message) return ErrorMessages.genericError;
let formatted = message.trim();
// Capitalize first letter
formatted = formatted.charAt(0).toUpperCase() + formatted.slice(1);
// Add period if missing
if (!formatted.endsWith('.') && !formatted.endsWith('!') && !formatted.endsWith('?')) {
formatted += '.';
}
return formatted;
}
/**
* Combine multiple field errors into a single message
*/
export function combineFieldErrors(errors: { field: string; message: string }[]): string {
if (errors.length === 0) return '';
if (errors.length === 1) return errors[0].message;
return `Please fix ${errors.length} errors: ${errors.map((e) => e.message).join(', ')}`;
}
/**
* Get plural form for error count
*/
export function getErrorCountText(count: number): string {
if (count === 0) return 'No errors';
if (count === 1) return '1 error';
return `${count} errors`;
}
export default ErrorMessages;