- 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>
252 lines
5.4 KiB
TypeScript
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,
|
|
},
|
|
});
|