WellNuo/components/ui/ScreenLoading.tsx
Sergei 2b36f801f1 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>
2026-02-01 10:11:14 -08:00

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,
},
});