Implemented a reusable useDebounce hook to prevent rapid-fire clicks on refresh buttons throughout the application. Changes: - Created hooks/useDebounce.ts with configurable delay and leading/trailing edge options - Added comprehensive unit tests in hooks/__tests__/useDebounce.test.ts - Applied debouncing to dashboard WebView refresh button (app/(tabs)/dashboard.tsx) - Applied debouncing to beneficiary detail pull-to-refresh (app/(tabs)/beneficiaries/[id]/index.tsx) - Applied debouncing to equipment screen refresh (app/(tabs)/beneficiaries/[id]/equipment.tsx) - Applied debouncing to all error retry buttons (components/ui/ErrorMessage.tsx) - Fixed jest.setup.js to properly mock React Native modules - Added implementation documentation in docs/DEBOUNCE_IMPLEMENTATION.md Technical details: - Default 1-second debounce delay - Leading edge execution (immediate first call, then debounce) - Type-safe with TypeScript generics - Automatic cleanup on component unmount 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
198 lines
5.4 KiB
TypeScript
198 lines
5.4 KiB
TypeScript
import React from 'react';
|
|
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
|
|
import { useDebounce } from '@/hooks/useDebounce';
|
|
|
|
interface ErrorMessageProps {
|
|
message: string;
|
|
onRetry?: () => void;
|
|
onSkip?: () => void;
|
|
onDismiss?: () => void;
|
|
}
|
|
|
|
export function ErrorMessage({ message, onRetry, onSkip, onDismiss }: ErrorMessageProps) {
|
|
const { debouncedFn: debouncedRetry } = useDebounce(onRetry || (() => {}), { delay: 1000 });
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<View style={styles.content}>
|
|
<Ionicons name="alert-circle" size={24} color={AppColors.error} />
|
|
<Text style={styles.message}>{message}</Text>
|
|
</View>
|
|
|
|
<View style={styles.actions}>
|
|
{onRetry && (
|
|
<TouchableOpacity onPress={debouncedRetry} style={styles.button}>
|
|
<Ionicons name="refresh" size={18} color={AppColors.primary} />
|
|
<Text style={styles.buttonText}>Retry</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
{onSkip && (
|
|
<TouchableOpacity onPress={onSkip} style={styles.skipButton}>
|
|
<Ionicons name="arrow-forward" size={18} color={AppColors.textSecondary} />
|
|
<Text style={styles.skipButtonText}>Skip</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
{onDismiss && (
|
|
<TouchableOpacity onPress={onDismiss} style={styles.dismissButton}>
|
|
<Ionicons name="close" size={18} color={AppColors.textMuted} />
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
interface FullScreenErrorProps {
|
|
title?: string;
|
|
message: string;
|
|
onRetry?: () => void;
|
|
onSkip?: () => void;
|
|
}
|
|
|
|
export function FullScreenError({
|
|
title = 'Something went wrong',
|
|
message,
|
|
onRetry,
|
|
onSkip
|
|
}: FullScreenErrorProps) {
|
|
const { debouncedFn: debouncedRetry } = useDebounce(onRetry || (() => {}), { delay: 1000 });
|
|
|
|
return (
|
|
<View style={styles.fullScreenContainer}>
|
|
<Ionicons name="cloud-offline-outline" size={64} color={AppColors.textMuted} />
|
|
<Text style={styles.fullScreenTitle}>{title}</Text>
|
|
<Text style={styles.fullScreenMessage}>{message}</Text>
|
|
|
|
<View style={styles.fullScreenActions}>
|
|
{onRetry && (
|
|
<TouchableOpacity onPress={debouncedRetry} style={styles.retryButton}>
|
|
<Ionicons name="refresh" size={20} color={AppColors.white} />
|
|
<Text style={styles.retryButtonText}>Try Again</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
{onSkip && (
|
|
<TouchableOpacity onPress={onSkip} style={styles.fullScreenSkipButton}>
|
|
<Ionicons name="arrow-forward" size={20} color={AppColors.textSecondary} />
|
|
<Text style={styles.fullScreenSkipButtonText}>Skip</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
backgroundColor: '#FEE2E2',
|
|
borderRadius: BorderRadius.lg,
|
|
padding: Spacing.md,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
marginVertical: Spacing.sm,
|
|
},
|
|
content: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
flex: 1,
|
|
},
|
|
message: {
|
|
color: AppColors.error,
|
|
fontSize: FontSizes.sm,
|
|
marginLeft: Spacing.sm,
|
|
flex: 1,
|
|
},
|
|
actions: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
},
|
|
button: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: Spacing.sm,
|
|
paddingVertical: Spacing.xs,
|
|
},
|
|
buttonText: {
|
|
color: AppColors.primary,
|
|
fontSize: FontSizes.sm,
|
|
fontWeight: '500',
|
|
marginLeft: Spacing.xs,
|
|
},
|
|
skipButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: Spacing.sm,
|
|
paddingVertical: Spacing.xs,
|
|
marginLeft: Spacing.xs,
|
|
},
|
|
skipButtonText: {
|
|
color: AppColors.textSecondary,
|
|
fontSize: FontSizes.sm,
|
|
fontWeight: '500',
|
|
marginLeft: Spacing.xs,
|
|
},
|
|
dismissButton: {
|
|
padding: Spacing.xs,
|
|
marginLeft: Spacing.xs,
|
|
},
|
|
// Full Screen Error
|
|
fullScreenContainer: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
padding: Spacing.xl,
|
|
backgroundColor: AppColors.background,
|
|
},
|
|
fullScreenTitle: {
|
|
fontSize: FontSizes.xl,
|
|
fontWeight: '600',
|
|
color: AppColors.textPrimary,
|
|
marginTop: Spacing.lg,
|
|
textAlign: 'center',
|
|
},
|
|
fullScreenMessage: {
|
|
fontSize: FontSizes.base,
|
|
color: AppColors.textSecondary,
|
|
marginTop: Spacing.sm,
|
|
textAlign: 'center',
|
|
},
|
|
fullScreenActions: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: Spacing.md,
|
|
marginTop: Spacing.xl,
|
|
},
|
|
retryButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
backgroundColor: AppColors.primary,
|
|
paddingVertical: Spacing.sm + 4,
|
|
paddingHorizontal: Spacing.lg,
|
|
borderRadius: BorderRadius.lg,
|
|
},
|
|
retryButtonText: {
|
|
color: AppColors.white,
|
|
fontSize: FontSizes.base,
|
|
fontWeight: '600',
|
|
marginLeft: Spacing.sm,
|
|
},
|
|
fullScreenSkipButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
backgroundColor: AppColors.surfaceSecondary,
|
|
paddingVertical: Spacing.sm + 4,
|
|
paddingHorizontal: Spacing.lg,
|
|
borderRadius: BorderRadius.lg,
|
|
borderWidth: 1,
|
|
borderColor: AppColors.border,
|
|
},
|
|
fullScreenSkipButtonText: {
|
|
color: AppColors.textSecondary,
|
|
fontSize: FontSizes.base,
|
|
fontWeight: '600',
|
|
marginLeft: Spacing.sm,
|
|
},
|
|
});
|