Add comprehensive loading state management system

- Add useLoadingState hook with data/loading/error state management
- Add useSimpleLoading hook for basic boolean loading state
- Add useMultipleLoadingStates hook for tracking multiple items
- Create Skeleton component with shimmer animation for placeholders
- Create specialized skeletons: SkeletonText, SkeletonAvatar, SkeletonCard,
  SkeletonListItem, SkeletonBeneficiaryCard, SkeletonSensorCard
- Create LoadingOverlay components: modal, inline, and card variants
- Create ScreenLoading wrapper with loading/error/content states
- Create RefreshableScreen with built-in pull-to-refresh
- Create EmptyState and LoadingButtonState utility components
- Add comprehensive tests for all components and hooks (61 tests passing)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sergei 2026-02-01 10:11:14 -08:00
parent 610104090a
commit 2b36f801f1
8 changed files with 1486 additions and 0 deletions

View File

@ -0,0 +1,57 @@
import React from 'react';
import { render } from '@testing-library/react-native';
import {
LoadingOverlay,
InlineLoadingOverlay,
LoadingCardOverlay,
} from '@/components/ui/LoadingOverlay';
describe('LoadingOverlay', () => {
it('renders nothing when visible is false', () => {
const { toJSON } = render(<LoadingOverlay visible={false} />);
expect(toJSON()).toBeNull();
});
it('renders modal when visible is true', () => {
const { getByText } = render(<LoadingOverlay visible={true} />);
expect(getByText('Loading...')).toBeTruthy();
});
it('renders custom message', () => {
const { getByText } = render(
<LoadingOverlay visible={true} message="Saving..." />
);
expect(getByText('Saving...')).toBeTruthy();
});
});
describe('InlineLoadingOverlay', () => {
it('renders nothing when visible is false', () => {
const { toJSON } = render(<InlineLoadingOverlay visible={false} />);
expect(toJSON()).toBeNull();
});
it('renders overlay when visible is true', () => {
const { toJSON } = render(<InlineLoadingOverlay visible={true} />);
expect(toJSON()).toBeTruthy();
});
it('renders with message', () => {
const { getByText } = render(
<InlineLoadingOverlay visible={true} message="Loading data..." />
);
expect(getByText('Loading data...')).toBeTruthy();
});
});
describe('LoadingCardOverlay', () => {
it('renders nothing when visible is false', () => {
const { toJSON } = render(<LoadingCardOverlay visible={false} />);
expect(toJSON()).toBeNull();
});
it('renders overlay when visible is true', () => {
const { toJSON } = render(<LoadingCardOverlay visible={true} />);
expect(toJSON()).toBeTruthy();
});
});

View File

@ -0,0 +1,168 @@
import React from 'react';
import { Text, View } from 'react-native';
import { render, fireEvent } from '@testing-library/react-native';
import {
ScreenLoading,
RefreshableScreen,
EmptyState,
LoadingButtonState,
} from '@/components/ui/ScreenLoading';
describe('ScreenLoading', () => {
it('renders children when not loading and no error', () => {
const { getByText } = render(
<ScreenLoading isLoading={false} error={null}>
<Text>Content</Text>
</ScreenLoading>
);
expect(getByText('Content')).toBeTruthy();
});
it('renders loading state when isLoading is true', () => {
const { getByText } = render(
<ScreenLoading isLoading={true} error={null}>
<Text>Content</Text>
</ScreenLoading>
);
expect(getByText('Loading...')).toBeTruthy();
});
it('renders custom loading message', () => {
const { getByText } = render(
<ScreenLoading
isLoading={true}
error={null}
loadingMessage="Fetching data..."
>
<Text>Content</Text>
</ScreenLoading>
);
expect(getByText('Fetching data...')).toBeTruthy();
});
it('renders error state when error is provided', () => {
const { getByText } = render(
<ScreenLoading isLoading={false} error="Network error">
<Text>Content</Text>
</ScreenLoading>
);
expect(getByText('Something went wrong')).toBeTruthy();
expect(getByText('Network error')).toBeTruthy();
});
it('renders retry button when onRetry is provided', () => {
const onRetry = jest.fn();
const { getByText } = render(
<ScreenLoading isLoading={false} error="Error" onRetry={onRetry}>
<Text>Content</Text>
</ScreenLoading>
);
expect(getByText('Try Again')).toBeTruthy();
});
it('calls onRetry when retry button is pressed', () => {
const onRetry = jest.fn();
const { getByText } = render(
<ScreenLoading isLoading={false} error="Error" onRetry={onRetry}>
<Text>Content</Text>
</ScreenLoading>
);
fireEvent.press(getByText('Try Again'));
expect(onRetry).toHaveBeenCalledTimes(1);
});
it('does not render retry button when onRetry is not provided', () => {
const { queryByText } = render(
<ScreenLoading isLoading={false} error="Error">
<Text>Content</Text>
</ScreenLoading>
);
expect(queryByText('Try Again')).toBeNull();
});
});
describe('RefreshableScreen', () => {
it('renders children', () => {
const onRefresh = jest.fn();
const { getByText } = render(
<RefreshableScreen isRefreshing={false} onRefresh={onRefresh}>
<Text>Content</Text>
</RefreshableScreen>
);
expect(getByText('Content')).toBeTruthy();
});
});
describe('EmptyState', () => {
it('renders title and description', () => {
const { getByText } = render(
<EmptyState
title="No items"
description="Add some items to get started"
/>
);
expect(getByText('No items')).toBeTruthy();
expect(getByText('Add some items to get started')).toBeTruthy();
});
it('renders action button when provided', () => {
const onAction = jest.fn();
const { getByText } = render(
<EmptyState
title="No items"
actionLabel="Add Item"
onAction={onAction}
/>
);
expect(getByText('Add Item')).toBeTruthy();
});
it('calls onAction when action button is pressed', () => {
const onAction = jest.fn();
const { getByText } = render(
<EmptyState
title="No items"
actionLabel="Add Item"
onAction={onAction}
/>
);
fireEvent.press(getByText('Add Item'));
expect(onAction).toHaveBeenCalledTimes(1);
});
it('does not render action button when onAction is not provided', () => {
const { queryByText } = render(
<EmptyState title="No items" actionLabel="Add Item" />
);
expect(queryByText('Add Item')).toBeNull();
});
});
describe('LoadingButtonState', () => {
it('renders children when not loading', () => {
const { getByText } = render(
<LoadingButtonState isLoading={false}>
<Text>Submit</Text>
</LoadingButtonState>
);
expect(getByText('Submit')).toBeTruthy();
});
it('renders loading state when isLoading is true', () => {
const { getByText } = render(
<LoadingButtonState isLoading={true}>
<Text>Submit</Text>
</LoadingButtonState>
);
expect(getByText('Loading...')).toBeTruthy();
});
it('renders custom loading text', () => {
const { getByText } = render(
<LoadingButtonState isLoading={true} loadingText="Saving...">
<Text>Submit</Text>
</LoadingButtonState>
);
expect(getByText('Saving...')).toBeTruthy();
});
});

View File

@ -0,0 +1,127 @@
import React from 'react';
import { render } from '@testing-library/react-native';
import {
Skeleton,
SkeletonText,
SkeletonAvatar,
SkeletonCard,
SkeletonListItem,
SkeletonBeneficiaryCard,
SkeletonSensorCard,
} from '@/components/ui/Skeleton';
describe('Skeleton', () => {
it('renders with default props', () => {
const { toJSON } = render(<Skeleton />);
const tree = toJSON();
expect(tree).toBeTruthy();
});
it('renders with custom width and height', () => {
const { toJSON } = render(<Skeleton width={100} height={50} />);
const tree = toJSON();
expect(tree).toBeTruthy();
});
it('renders with custom border radius', () => {
const { toJSON } = render(<Skeleton borderRadius={20} />);
const tree = toJSON();
expect(tree).toBeTruthy();
});
it('renders with percentage width', () => {
const { toJSON } = render(<Skeleton width="80%" />);
const tree = toJSON();
expect(tree).toBeTruthy();
});
});
describe('SkeletonText', () => {
it('renders single line by default', () => {
const { toJSON } = render(<SkeletonText />);
const tree = toJSON();
expect(tree).toBeTruthy();
});
it('renders multiple lines', () => {
const { toJSON } = render(<SkeletonText lines={3} />);
const tree = toJSON();
expect(tree).toBeTruthy();
});
it('renders with custom line height', () => {
const { toJSON } = render(<SkeletonText lineHeight={20} />);
const tree = toJSON();
expect(tree).toBeTruthy();
});
it('renders with custom last line width', () => {
const { toJSON } = render(<SkeletonText lines={2} lastLineWidth="50%" />);
const tree = toJSON();
expect(tree).toBeTruthy();
});
});
describe('SkeletonAvatar', () => {
it('renders with default size', () => {
const { toJSON } = render(<SkeletonAvatar />);
const tree = toJSON();
expect(tree).toBeTruthy();
});
it('renders with custom size', () => {
const { toJSON } = render(<SkeletonAvatar size={64} />);
const tree = toJSON();
expect(tree).toBeTruthy();
});
});
describe('SkeletonCard', () => {
it('renders with default height', () => {
const { toJSON } = render(<SkeletonCard />);
const tree = toJSON();
expect(tree).toBeTruthy();
});
it('renders with custom height', () => {
const { toJSON } = render(<SkeletonCard height={200} />);
const tree = toJSON();
expect(tree).toBeTruthy();
});
});
describe('SkeletonListItem', () => {
it('renders with default props', () => {
const { toJSON } = render(<SkeletonListItem />);
const tree = toJSON();
expect(tree).toBeTruthy();
});
it('renders with custom avatar size', () => {
const { toJSON } = render(<SkeletonListItem avatarSize={64} />);
const tree = toJSON();
expect(tree).toBeTruthy();
});
it('renders with custom number of lines', () => {
const { toJSON } = render(<SkeletonListItem lines={3} />);
const tree = toJSON();
expect(tree).toBeTruthy();
});
});
describe('SkeletonBeneficiaryCard', () => {
it('renders beneficiary card skeleton', () => {
const { toJSON } = render(<SkeletonBeneficiaryCard />);
const tree = toJSON();
expect(tree).toBeTruthy();
});
});
describe('SkeletonSensorCard', () => {
it('renders sensor card skeleton', () => {
const { toJSON } = render(<SkeletonSensorCard />);
const tree = toJSON();
expect(tree).toBeTruthy();
});
});

View File

@ -0,0 +1,130 @@
import React from 'react';
import {
View,
Text,
StyleSheet,
ActivityIndicator,
Modal,
ViewStyle,
} from 'react-native';
import { AppColors, BorderRadius, FontSizes, Spacing, Shadows } from '@/constants/theme';
interface LoadingOverlayProps {
visible: boolean;
message?: string;
transparent?: boolean;
}
/**
* Full-screen modal loading overlay
* Use for operations that block the entire UI
*/
export function LoadingOverlay({
visible,
message = 'Loading...',
transparent = true,
}: LoadingOverlayProps) {
if (!visible) return null;
return (
<Modal
visible={visible}
transparent={transparent}
animationType="fade"
statusBarTranslucent
>
<View style={styles.overlay}>
<View style={styles.container}>
<ActivityIndicator size="large" color={AppColors.primary} />
{message && <Text style={styles.message}>{message}</Text>}
</View>
</View>
</Modal>
);
}
interface InlineLoadingOverlayProps {
visible: boolean;
message?: string;
style?: ViewStyle;
}
/**
* Inline loading overlay that covers its parent container
* Use for loading states within a specific section
*/
export function InlineLoadingOverlay({
visible,
message,
style,
}: InlineLoadingOverlayProps) {
if (!visible) return null;
return (
<View style={[styles.inlineOverlay, style]}>
<ActivityIndicator size="large" color={AppColors.primary} />
{message && <Text style={styles.inlineMessage}>{message}</Text>}
</View>
);
}
interface LoadingCardOverlayProps {
visible: boolean;
}
/**
* Card-style loading overlay
* Use for overlay on cards or smaller components
*/
export function LoadingCardOverlay({ visible }: LoadingCardOverlayProps) {
if (!visible) return null;
return (
<View style={styles.cardOverlay}>
<ActivityIndicator size="small" color={AppColors.primary} />
</View>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: AppColors.overlay,
justifyContent: 'center',
alignItems: 'center',
},
container: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.xl,
alignItems: 'center',
minWidth: 150,
...Shadows.lg,
},
message: {
marginTop: Spacing.md,
fontSize: FontSizes.base,
color: AppColors.textSecondary,
textAlign: 'center',
},
inlineOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
justifyContent: 'center',
alignItems: 'center',
borderRadius: BorderRadius.lg,
},
inlineMessage: {
marginTop: Spacing.sm,
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
textAlign: 'center',
},
cardOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(255, 255, 255, 0.8)',
justifyContent: 'center',
alignItems: 'center',
borderRadius: BorderRadius.lg,
},
});

View File

@ -0,0 +1,272 @@
import React from 'react';
import {
View,
Text,
StyleSheet,
ActivityIndicator,
TouchableOpacity,
ScrollView,
RefreshControl,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
AppColors,
BorderRadius,
FontSizes,
Spacing,
FontWeights,
Shadows,
} from '@/constants/theme';
interface ScreenLoadingProps {
isLoading: boolean;
error?: string | null;
children: React.ReactNode;
onRetry?: () => void;
loadingMessage?: string;
fullScreen?: boolean;
}
/**
* Wrapper component for screens with loading/error/content states
* Provides consistent loading and error UI across the app
*/
export function ScreenLoading({
isLoading,
error,
children,
onRetry,
loadingMessage = 'Loading...',
fullScreen = true,
}: ScreenLoadingProps) {
if (isLoading) {
return (
<View style={[styles.container, fullScreen && styles.fullScreen]}>
<ActivityIndicator size="large" color={AppColors.primary} />
<Text style={styles.loadingMessage}>{loadingMessage}</Text>
</View>
);
}
if (error) {
return (
<View style={[styles.container, fullScreen && styles.fullScreen]}>
<View style={styles.errorIcon}>
<Ionicons name="alert-circle" size={48} color={AppColors.error} />
</View>
<Text style={styles.errorTitle}>Something went wrong</Text>
<Text style={styles.errorMessage}>{error}</Text>
{onRetry && (
<TouchableOpacity style={styles.retryButton} onPress={onRetry}>
<Ionicons name="refresh" size={18} color={AppColors.white} />
<Text style={styles.retryText}>Try Again</Text>
</TouchableOpacity>
)}
</View>
);
}
return <>{children}</>;
}
interface RefreshableScreenProps {
isRefreshing: boolean;
onRefresh: () => void;
children: React.ReactNode;
contentContainerStyle?: object;
showsVerticalScrollIndicator?: boolean;
}
/**
* ScrollView with built-in pull-to-refresh
* Provides consistent refresh behavior across the app
*/
export function RefreshableScreen({
isRefreshing,
onRefresh,
children,
contentContainerStyle,
showsVerticalScrollIndicator = false,
}: RefreshableScreenProps) {
return (
<ScrollView
style={styles.scrollView}
contentContainerStyle={contentContainerStyle}
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={onRefresh}
tintColor={AppColors.primary}
colors={[AppColors.primary]}
/>
}
>
{children}
</ScrollView>
);
}
interface EmptyStateProps {
icon?: keyof typeof Ionicons.glyphMap;
title: string;
description?: string;
actionLabel?: string;
onAction?: () => void;
}
/**
* Empty state component for when there's no content to display
*/
export function EmptyState({
icon = 'folder-open-outline',
title,
description,
actionLabel,
onAction,
}: EmptyStateProps) {
return (
<View style={styles.emptyContainer}>
<View style={styles.emptyIcon}>
<Ionicons name={icon} size={48} color={AppColors.textMuted} />
</View>
<Text style={styles.emptyTitle}>{title}</Text>
{description && <Text style={styles.emptyDescription}>{description}</Text>}
{actionLabel && onAction && (
<TouchableOpacity style={styles.emptyAction} onPress={onAction}>
<Text style={styles.emptyActionText}>{actionLabel}</Text>
</TouchableOpacity>
)}
</View>
);
}
interface LoadingButtonStateProps {
isLoading: boolean;
loadingText?: string;
children: React.ReactNode;
}
/**
* Wrapper for button content that shows loading state
*/
export function LoadingButtonState({
isLoading,
loadingText = 'Loading...',
children,
}: LoadingButtonStateProps) {
if (isLoading) {
return (
<View style={styles.buttonLoading}>
<ActivityIndicator size="small" color={AppColors.white} />
{loadingText && <Text style={styles.buttonLoadingText}>{loadingText}</Text>}
</View>
);
}
return <>{children}</>;
}
const styles = StyleSheet.create({
container: {
justifyContent: 'center',
alignItems: 'center',
padding: Spacing.xl,
},
fullScreen: {
flex: 1,
backgroundColor: AppColors.background,
},
loadingMessage: {
marginTop: Spacing.md,
fontSize: FontSizes.base,
color: AppColors.textSecondary,
textAlign: 'center',
},
errorIcon: {
marginBottom: Spacing.md,
},
errorTitle: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
marginBottom: Spacing.sm,
textAlign: 'center',
},
errorMessage: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
textAlign: 'center',
marginBottom: Spacing.lg,
paddingHorizontal: Spacing.lg,
},
retryButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.primary,
paddingHorizontal: Spacing.lg,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
gap: Spacing.sm,
...Shadows.primary,
},
retryText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
scrollView: {
flex: 1,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: Spacing.xl,
},
emptyIcon: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: AppColors.surfaceSecondary,
justifyContent: 'center',
alignItems: 'center',
marginBottom: Spacing.lg,
},
emptyTitle: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
textAlign: 'center',
marginBottom: Spacing.sm,
},
emptyDescription: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
textAlign: 'center',
marginBottom: Spacing.lg,
paddingHorizontal: Spacing.lg,
},
emptyAction: {
backgroundColor: AppColors.primary,
paddingHorizontal: Spacing.lg,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
},
emptyActionText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
buttonLoading: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
},
buttonLoadingText: {
fontSize: FontSizes.base,
color: AppColors.white,
fontWeight: FontWeights.medium,
},
});

251
components/ui/Skeleton.tsx Normal file
View File

@ -0,0 +1,251 @@
import React, { useEffect, useRef } from 'react';
import {
View,
StyleSheet,
Animated,
ViewStyle,
Easing,
DimensionValue,
} from 'react-native';
import { AppColors, BorderRadius, Spacing } from '@/constants/theme';
interface SkeletonProps {
width?: DimensionValue;
height?: DimensionValue;
borderRadius?: number;
style?: ViewStyle;
}
/**
* Animated skeleton loader for placeholder content
* Uses a shimmer effect to indicate loading state
*/
export function Skeleton({
width = '100%',
height = 20,
borderRadius = BorderRadius.sm,
style,
}: SkeletonProps) {
const shimmerAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
const animation = Animated.loop(
Animated.sequence([
Animated.timing(shimmerAnim, {
toValue: 1,
duration: 1000,
easing: Easing.ease,
useNativeDriver: true,
}),
Animated.timing(shimmerAnim, {
toValue: 0,
duration: 1000,
easing: Easing.ease,
useNativeDriver: true,
}),
])
);
animation.start();
return () => animation.stop();
}, [shimmerAnim]);
const animatedOpacity = shimmerAnim.interpolate({
inputRange: [0, 1],
outputRange: [0.3, 0.7],
});
return (
<Animated.View
style={[
styles.skeleton,
{
width,
height,
borderRadius,
opacity: animatedOpacity,
},
style,
]}
/>
);
}
/**
* Skeleton for text lines - common use case
*/
export function SkeletonText({
lines = 1,
lineHeight = 16,
spacing = Spacing.sm,
lastLineWidth = '60%',
style,
}: {
lines?: number;
lineHeight?: number;
spacing?: number;
lastLineWidth?: DimensionValue;
style?: ViewStyle;
}) {
return (
<View style={style}>
{Array.from({ length: lines }).map((_, index) => (
<Skeleton
key={index}
height={lineHeight}
width={index === lines - 1 && lines > 1 ? lastLineWidth : '100%'}
style={index < lines - 1 ? { marginBottom: spacing } : undefined}
/>
))}
</View>
);
}
/**
* Skeleton for avatar/circular elements
*/
export function SkeletonAvatar({
size = 48,
style,
}: {
size?: number;
style?: ViewStyle;
}) {
return (
<Skeleton
width={size}
height={size}
borderRadius={size / 2}
style={style}
/>
);
}
/**
* Skeleton for a card-like element
*/
export function SkeletonCard({
height = 100,
style,
}: {
height?: DimensionValue;
style?: ViewStyle;
}) {
return (
<View style={[styles.card, style]}>
<Skeleton height={height} borderRadius={BorderRadius.lg} />
</View>
);
}
/**
* Skeleton for list items with avatar and text
*/
export function SkeletonListItem({
avatarSize = 48,
lines = 2,
style,
}: {
avatarSize?: number;
lines?: number;
style?: ViewStyle;
}) {
return (
<View style={[styles.listItem, style]}>
<SkeletonAvatar size={avatarSize} />
<View style={styles.listItemContent}>
<SkeletonText lines={lines} lastLineWidth="70%" />
</View>
</View>
);
}
/**
* Skeleton for beneficiary cards
*/
export function SkeletonBeneficiaryCard({ style }: { style?: ViewStyle }) {
return (
<View style={[styles.beneficiaryCard, style]}>
<View style={styles.beneficiaryHeader}>
<SkeletonAvatar size={56} />
<View style={styles.beneficiaryInfo}>
<Skeleton height={20} width="70%" style={{ marginBottom: Spacing.xs }} />
<Skeleton height={14} width="50%" />
</View>
</View>
<View style={styles.beneficiaryActions}>
<Skeleton height={36} width={80} borderRadius={BorderRadius.md} />
<Skeleton height={36} width={80} borderRadius={BorderRadius.md} />
</View>
</View>
);
}
/**
* Skeleton for sensor health cards
*/
export function SkeletonSensorCard({ style }: { style?: ViewStyle }) {
return (
<View style={[styles.sensorCard, style]}>
<View style={styles.sensorHeader}>
<Skeleton height={24} width={24} borderRadius={BorderRadius.sm} />
<Skeleton height={18} width="60%" />
</View>
<Skeleton height={40} width="100%" style={{ marginTop: Spacing.md }} />
<Skeleton height={12} width="80%" style={{ marginTop: Spacing.sm }} />
</View>
);
}
const styles = StyleSheet.create({
skeleton: {
backgroundColor: AppColors.surfaceSecondary,
},
card: {
marginBottom: Spacing.md,
},
listItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: Spacing.md,
paddingHorizontal: Spacing.md,
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
marginBottom: Spacing.sm,
},
listItemContent: {
flex: 1,
marginLeft: Spacing.md,
},
beneficiaryCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.lg,
marginBottom: Spacing.md,
},
beneficiaryHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: Spacing.md,
},
beneficiaryInfo: {
flex: 1,
marginLeft: Spacing.md,
},
beneficiaryActions: {
flexDirection: 'row',
gap: Spacing.sm,
},
sensorCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
marginBottom: Spacing.sm,
},
sensorHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
},
});

View File

@ -0,0 +1,284 @@
import { renderHook, act, cleanup } from '@testing-library/react-native';
import {
useLoadingState,
useSimpleLoading,
useMultipleLoadingStates,
} from '@/hooks/useLoadingState';
// Cleanup after each test to prevent unmounted renderer issues
afterEach(() => {
cleanup();
});
describe('useLoadingState', () => {
it('initializes with default state', () => {
const { result } = renderHook(() => useLoadingState());
expect(result.current.data).toBeNull();
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBeNull();
expect(result.current.isRefreshing).toBe(false);
});
it('initializes with provided initial data', () => {
const { result } = renderHook(() =>
useLoadingState({ initialData: { id: 1, name: 'Test' } })
);
expect(result.current.data).toEqual({ id: 1, name: 'Test' });
});
it('setData updates data and clears error', () => {
const { result } = renderHook(() => useLoadingState<string>());
act(() => {
result.current.setError('Previous error');
});
act(() => {
result.current.setData('new data');
});
expect(result.current.data).toBe('new data');
expect(result.current.error).toBeNull();
});
it('setLoading updates loading state', () => {
const { result } = renderHook(() => useLoadingState());
act(() => {
result.current.setLoading(true);
});
expect(result.current.isLoading).toBe(true);
act(() => {
result.current.setLoading(false);
});
expect(result.current.isLoading).toBe(false);
});
it('setError updates error and clears loading states', () => {
const { result } = renderHook(() => useLoadingState());
act(() => {
result.current.setLoading(true);
});
act(() => {
result.current.setError('Something went wrong');
});
expect(result.current.error).toBe('Something went wrong');
expect(result.current.isLoading).toBe(false);
expect(result.current.isRefreshing).toBe(false);
});
it('setRefreshing updates refreshing state', () => {
const { result } = renderHook(() => useLoadingState());
act(() => {
result.current.setRefreshing(true);
});
expect(result.current.isRefreshing).toBe(true);
});
it('reset restores initial state', () => {
const { result } = renderHook(() =>
useLoadingState({ initialData: 'initial' })
);
act(() => {
result.current.setData('modified');
result.current.setLoading(true);
result.current.setError('error');
});
act(() => {
result.current.reset();
});
expect(result.current.data).toBe('initial');
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBeNull();
expect(result.current.isRefreshing).toBe(false);
});
it('execute handles successful async function', async () => {
const { result } = renderHook(() => useLoadingState<string>());
await act(async () => {
await result.current.execute(async () => {
return 'success';
});
});
expect(result.current.data).toBe('success');
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBeNull();
});
it('execute handles async function error', async () => {
const { result } = renderHook(() => useLoadingState<string>());
await act(async () => {
await result.current.execute(async () => {
throw new Error('Network error');
});
});
expect(result.current.error).toBe('Network error');
expect(result.current.isLoading).toBe(false);
});
it('execute with refresh option returns data', async () => {
const { result } = renderHook(() => useLoadingState<string>());
await act(async () => {
await result.current.execute(
async () => {
return 'refreshed data';
},
{ refresh: true }
);
});
expect(result.current.data).toBe('refreshed data');
expect(result.current.isRefreshing).toBe(false);
});
it('execute uses transform function', async () => {
const { result } = renderHook(() => useLoadingState<string>());
await act(async () => {
await result.current.execute(
async () => {
return { value: 'raw' };
},
{ transform: (res) => res.value.toUpperCase() }
);
});
expect(result.current.data).toBe('RAW');
});
});
describe('useSimpleLoading', () => {
it('initializes with default state', () => {
const { result } = renderHook(() => useSimpleLoading());
expect(result.current.isLoading).toBe(false);
});
it('initializes with provided initial state', () => {
const { result } = renderHook(() => useSimpleLoading(true));
expect(result.current.isLoading).toBe(true);
});
it('startLoading sets isLoading to true', () => {
const { result } = renderHook(() => useSimpleLoading());
act(() => {
result.current.startLoading();
});
expect(result.current.isLoading).toBe(true);
});
it('stopLoading sets isLoading to false', () => {
const { result } = renderHook(() => useSimpleLoading(true));
act(() => {
result.current.stopLoading();
});
expect(result.current.isLoading).toBe(false);
});
it('toggleLoading toggles isLoading', () => {
const { result } = renderHook(() => useSimpleLoading(false));
act(() => {
result.current.toggleLoading();
});
expect(result.current.isLoading).toBe(true);
act(() => {
result.current.toggleLoading();
});
expect(result.current.isLoading).toBe(false);
});
it('withLoading wraps async function', async () => {
const { result } = renderHook(() => useSimpleLoading());
await act(async () => {
const returnValue = await result.current.withLoading(async () => {
return 'done';
});
expect(returnValue).toBe('done');
});
expect(result.current.isLoading).toBe(false);
});
});
describe('useMultipleLoadingStates', () => {
it('initializes with empty loading states', () => {
const { result } = renderHook(() => useMultipleLoadingStates());
expect(result.current.loadingStates).toEqual({});
expect(result.current.anyLoading).toBe(false);
});
it('setLoading updates individual loading state', () => {
const { result } = renderHook(() => useMultipleLoadingStates());
act(() => {
result.current.setLoading('item-1', true);
});
expect(result.current.isLoading('item-1')).toBe(true);
expect(result.current.isLoading('item-2')).toBe(false);
expect(result.current.anyLoading).toBe(true);
});
it('tracks multiple loading states', () => {
const { result } = renderHook(() => useMultipleLoadingStates());
act(() => {
result.current.setLoading('item-1', true);
result.current.setLoading('item-2', true);
});
expect(result.current.isLoading('item-1')).toBe(true);
expect(result.current.isLoading('item-2')).toBe(true);
expect(result.current.anyLoading).toBe(true);
act(() => {
result.current.setLoading('item-1', false);
});
expect(result.current.isLoading('item-1')).toBe(false);
expect(result.current.isLoading('item-2')).toBe(true);
expect(result.current.anyLoading).toBe(true);
});
it('clearAll clears all loading states', () => {
const { result } = renderHook(() => useMultipleLoadingStates());
act(() => {
result.current.setLoading('item-1', true);
result.current.setLoading('item-2', true);
});
act(() => {
result.current.clearAll();
});
expect(result.current.loadingStates).toEqual({});
expect(result.current.anyLoading).toBe(false);
});
});

197
hooks/useLoadingState.ts Normal file
View File

@ -0,0 +1,197 @@
import { useState, useCallback } from 'react';
interface LoadingState<T> {
data: T | null;
isLoading: boolean;
error: string | null;
isRefreshing: boolean;
}
interface UseLoadingStateOptions<T> {
initialData?: T | null;
}
interface UseLoadingStateReturn<T> extends LoadingState<T> {
setData: (data: T | null) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
setRefreshing: (refreshing: boolean) => void;
reset: () => void;
execute: <R = T>(
asyncFn: () => Promise<R>,
options?: { refresh?: boolean; transform?: (result: R) => T }
) => Promise<R | undefined>;
}
/**
* Custom hook for managing loading states
* Provides consistent loading, error, and data state management
*/
export function useLoadingState<T>(
options: UseLoadingStateOptions<T> = {}
): UseLoadingStateReturn<T> {
const { initialData = null } = options;
const [state, setState] = useState<LoadingState<T>>({
data: initialData,
isLoading: false,
error: null,
isRefreshing: false,
});
const setData = useCallback((data: T | null) => {
setState((prev) => ({ ...prev, data, error: null }));
}, []);
const setLoading = useCallback((isLoading: boolean) => {
setState((prev) => ({ ...prev, isLoading }));
}, []);
const setError = useCallback((error: string | null) => {
setState((prev) => ({ ...prev, error, isLoading: false, isRefreshing: false }));
}, []);
const setRefreshing = useCallback((isRefreshing: boolean) => {
setState((prev) => ({ ...prev, isRefreshing }));
}, []);
const reset = useCallback(() => {
setState({
data: initialData,
isLoading: false,
error: null,
isRefreshing: false,
});
}, [initialData]);
/**
* Execute an async function with automatic loading state management
* @param asyncFn - The async function to execute
* @param options.refresh - If true, sets isRefreshing instead of isLoading
* @param options.transform - Optional function to transform the result before setting data
*/
const execute = useCallback(
async <R = T>(
asyncFn: () => Promise<R>,
execOptions?: { refresh?: boolean; transform?: (result: R) => T }
): Promise<R | undefined> => {
const { refresh = false, transform } = execOptions || {};
try {
if (refresh) {
setState((prev) => ({ ...prev, isRefreshing: true, error: null }));
} else {
setState((prev) => ({ ...prev, isLoading: true, error: null }));
}
const result = await asyncFn();
// Transform and set data if transform function provided
if (transform) {
const transformedData = transform(result);
setState((prev) => ({
...prev,
data: transformedData,
isLoading: false,
isRefreshing: false,
}));
} else {
// If no transform, try to set result as data (if types match)
setState((prev) => ({
...prev,
data: result as unknown as T,
isLoading: false,
isRefreshing: false,
}));
}
return result;
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'An error occurred';
setState((prev) => ({
...prev,
error: errorMessage,
isLoading: false,
isRefreshing: false,
}));
return undefined;
}
},
[]
);
return {
...state,
setData,
setLoading,
setError,
setRefreshing,
reset,
execute,
};
}
/**
* Simple boolean loading state hook
* For cases where you just need a loading flag
*/
export function useSimpleLoading(initialState = false) {
const [isLoading, setIsLoading] = useState(initialState);
const startLoading = useCallback(() => setIsLoading(true), []);
const stopLoading = useCallback(() => setIsLoading(false), []);
const toggleLoading = useCallback(() => setIsLoading((prev) => !prev), []);
const withLoading = useCallback(
async <T>(asyncFn: () => Promise<T>): Promise<T> => {
setIsLoading(true);
try {
return await asyncFn();
} finally {
setIsLoading(false);
}
},
[]
);
return {
isLoading,
setIsLoading,
startLoading,
stopLoading,
toggleLoading,
withLoading,
};
}
/**
* Hook for managing multiple loading states by key
* Useful for list items that can have individual loading states
*/
export function useMultipleLoadingStates() {
const [loadingStates, setLoadingStates] = useState<Record<string, boolean>>({});
const setLoading = useCallback((key: string, loading: boolean) => {
setLoadingStates((prev) => ({ ...prev, [key]: loading }));
}, []);
const isLoading = useCallback(
(key: string) => loadingStates[key] || false,
[loadingStates]
);
const anyLoading = Object.values(loadingStates).some(Boolean);
const clearAll = useCallback(() => {
setLoadingStates({});
}, []);
return {
loadingStates,
setLoading,
isLoading,
anyLoading,
clearAll,
};
}