- 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>
273 lines
6.4 KiB
TypeScript
273 lines
6.4 KiB
TypeScript
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,
|
|
},
|
|
});
|