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:
parent
610104090a
commit
2b36f801f1
57
__tests__/components/LoadingOverlay.test.tsx
Normal file
57
__tests__/components/LoadingOverlay.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
168
__tests__/components/ScreenLoading.test.tsx
Normal file
168
__tests__/components/ScreenLoading.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
127
__tests__/components/Skeleton.test.tsx
Normal file
127
__tests__/components/Skeleton.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
130
components/ui/LoadingOverlay.tsx
Normal file
130
components/ui/LoadingOverlay.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
272
components/ui/ScreenLoading.tsx
Normal file
272
components/ui/ScreenLoading.tsx
Normal 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
251
components/ui/Skeleton.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
284
hooks/__tests__/useLoadingState.test.ts
Normal file
284
hooks/__tests__/useLoadingState.test.ts
Normal 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
197
hooks/useLoadingState.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user