From a238b7e35f8a0ece16f623854517ee27fa7d3422 Mon Sep 17 00:00:00 2001 From: Sergei Date: Sat, 31 Jan 2026 17:43:07 -0800 Subject: [PATCH] Add comprehensive error handling system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- __tests__/services/errorHandler.test.ts | 371 +++++++++++++++++++ __tests__/utils/errorMessages.test.ts | 226 ++++++++++++ components/errors/ErrorToast.tsx | 215 +++++++++++ components/errors/FieldError.tsx | 184 ++++++++++ components/errors/FullScreenError.tsx | 272 ++++++++++++++ components/errors/index.ts | 7 + contexts/ErrorContext.tsx | 384 +++++++++++++++++++ hooks/useAsync.ts | 466 ++++++++++++++++++++++++ hooks/useError.ts | 368 +++++++++++++++++++ services/errorHandler.ts | 378 +++++++++++++++++++ types/errors.ts | 268 ++++++++++++++ utils/errorMessages.ts | 238 ++++++++++++ 12 files changed, 3377 insertions(+) create mode 100644 __tests__/services/errorHandler.test.ts create mode 100644 __tests__/utils/errorMessages.test.ts create mode 100644 components/errors/ErrorToast.tsx create mode 100644 components/errors/FieldError.tsx create mode 100644 components/errors/FullScreenError.tsx create mode 100644 components/errors/index.ts create mode 100644 contexts/ErrorContext.tsx create mode 100644 hooks/useAsync.ts create mode 100644 hooks/useError.ts create mode 100644 services/errorHandler.ts create mode 100644 types/errors.ts create mode 100644 utils/errorMessages.ts diff --git a/__tests__/services/errorHandler.test.ts b/__tests__/services/errorHandler.test.ts new file mode 100644 index 0000000..2af02eb --- /dev/null +++ b/__tests__/services/errorHandler.test.ts @@ -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'); + }); + }); +}); diff --git a/__tests__/utils/errorMessages.test.ts b/__tests__/utils/errorMessages.test.ts new file mode 100644 index 0000000..7da2f0b --- /dev/null +++ b/__tests__/utils/errorMessages.test.ts @@ -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'); + }); + }); +}); diff --git a/components/errors/ErrorToast.tsx b/components/errors/ErrorToast.tsx new file mode 100644 index 0000000..e6ffbca --- /dev/null +++ b/components/errors/ErrorToast.tsx @@ -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 = { + 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; diff --git a/components/errors/FieldError.tsx b/components/errors/FieldError.tsx new file mode 100644 index 0000000..99c8fc9 --- /dev/null +++ b/components/errors/FieldError.tsx @@ -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 ( + + + {message} + + ); +} + +/** + * 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 ( + + + + + Please fix the following {errors.length === 1 ? 'error' : 'errors'}: + + + + {errors.map((error, index) => ( + + • + {error.message} + + ))} + + + ); +} + +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; diff --git a/components/errors/FullScreenError.tsx b/components/errors/FullScreenError.tsx new file mode 100644 index 0000000..1ff80f0 --- /dev/null +++ b/components/errors/FullScreenError.tsx @@ -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 = { + 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 ( + + + {/* Icon */} + + + + + {/* Title */} + {displayTitle} + + {/* Message */} + {displayMessage} + + {/* Action hint */} + {error?.actionHint && ( + {error.actionHint} + )} + + {/* Buttons */} + + {isRetryable && onRetry && ( + + + {retryLabel} + + )} + + {showDismiss && onDismiss && ( + + {dismissLabel} + + )} + + + {/* Error ID for support */} + {error?.errorId && __DEV__ && ( + Error ID: {error.errorId} + )} + + + ); +} + +/** + * 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 ( + + + + + + {title} + {message && {message}} + {actionLabel && onAction && ( + + {actionLabel} + + )} + + + ); +} + +/** + * OfflineState - Specific state for offline/no connection + */ +interface OfflineStateProps { + onRetry?: () => void; +} + +export function OfflineState({ onRetry }: OfflineStateProps) { + return ( + + + + + + No Internet Connection + + Please check your connection and try again. + + {onRetry && ( + + + Try Again + + )} + + + ); +} + +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; diff --git a/components/errors/index.ts b/components/errors/index.ts new file mode 100644 index 0000000..fee7b96 --- /dev/null +++ b/components/errors/index.ts @@ -0,0 +1,7 @@ +/** + * Error Components Export + */ + +export { ErrorToast } from './ErrorToast'; +export { FieldError, FieldErrorSummary } from './FieldError'; +export { FullScreenError, EmptyState, OfflineState } from './FullScreenError'; diff --git a/contexts/ErrorContext.tsx b/contexts/ErrorContext.tsx new file mode 100644 index 0000000..1993241 --- /dev/null +++ b/contexts/ErrorContext.tsx @@ -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(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 | 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( + () => ({ + 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 ( + {children} + ); +} + +// 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; diff --git a/hooks/useAsync.ts b/hooks/useAsync.ts new file mode 100644 index 0000000..2e59411 --- /dev/null +++ b/hooks/useAsync.ts @@ -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 { + 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 { + // 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 { + // State + state: AsyncState; + data: T | null; + error: AppError | null; + status: AsyncStatus; + isLoading: boolean; + isSuccess: boolean; + isError: boolean; + isIdle: boolean; + isStale: boolean; + + // Actions + execute: (...args: Args) => Promise; + reset: () => void; + setData: (data: T | ((prev: T | null) => T)) => void; + setError: (error: AppError | null) => void; + retry: () => Promise; + cancel: () => void; +} + +// Create initial state +function createInitialState(initialData?: T | null): AsyncState { + 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( + asyncFn: (...args: Args) => Promise>, + options: UseAsyncOptions = {} +): UseAsyncReturn { + const { + initialData, + immediate = false, + keepPreviousData = true, + staleTime, + autoRetry = false, + maxRetries = 3, + onSuccess, + onError, + onSettled, + } = options; + + const [state, setState] = useState>(() => + createInitialState(initialData) + ); + + // Refs + const mountedRef = useRef(true); + const abortControllerRef = useRef(null); + const lastArgsRef = useRef(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 => { + // 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 => { + 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 => { + 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( + asyncFn: (...args: Args) => Promise, + options: Omit, '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 => { + setState({ isLoading: true, error: null }); + + const executeWithRetry = async (attempt: number): Promise => { + 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; diff --git a/hooks/useError.ts b/hooks/useError.ts new file mode 100644 index 0000000..0bc2a3e --- /dev/null +++ b/hooks/useError.ts @@ -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) => void; + + // Set error from any value + setError: (error: unknown, context?: Record) => void; + + // Clear current error + clearError: () => void; + + // Clear all errors + clearAllErrors: () => void; + + // Handle API response (returns data if ok, sets error if not) + handleApiResponse: ( + response: ApiResponse, + context?: Record + ) => T | null; + + // Wrap async function with error handling + wrapAsync: ( + fn: () => Promise, + context?: Record + ) => Promise; + + // Execute with retry + executeWithRetry: ( + fn: () => Promise>, + context?: Record + ) => Promise; + + // 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(initialErrorState); + const retryCountRef = useRef>(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) => { + const appError = createErrorFromApi(error, context); + handleError(appError); + }, + [handleError] + ); + + // Set error from any value + const setError = useCallback( + (error: unknown, context?: Record) => { + 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( + (response: ApiResponse, context?: Record): 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 ( + fn: () => Promise, + context?: Record + ): Promise => { + try { + const result = await fn(); + clearError(); + return result; + } catch (error) { + setError(error, context); + return null; + } + }, + [clearError, setError] + ); + + // Execute with retry + const executeWithRetry = useCallback( + async ( + fn: () => Promise>, + context?: Record + ): Promise => { + const operationId = Date.now().toString(); + let attempt = 0; + const maxAttempts = maxRetries ?? 3; + + const execute = async (): Promise => { + 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({}); + + 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; diff --git a/services/errorHandler.ts b/services/errorHandler.ts new file mode 100644 index 0000000..f82c796 --- /dev/null +++ b/services/errorHandler.ts @@ -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 = { + // 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 = { + [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): 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 +): 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 +): 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 +): 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 }; diff --git a/types/errors.ts b/types/errors.ts new file mode 100644 index 0000000..63e2658 --- /dev/null +++ b/types/errors.ts @@ -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; + + // 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 = { + 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'; +} diff --git a/utils/errorMessages.ts b/utils/errorMessages.ts new file mode 100644 index 0000000..aac3e40 --- /dev/null +++ b/utils/errorMessages.ts @@ -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 = { + [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 = { + [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;