WellNuo/components/ui/Skeleton.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

252 lines
5.4 KiB
TypeScript

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