Files
Sauci/apps/mobile/src/components/ui/LoadingOverlay.tsx
T
jjbruntonandClaude Fable 5 6bc4e31719 chore(mobile): upgrade to Expo SDK 56
Bump expo, react, react-native, and all expo-* / react-native-* peers to
the SDK 56 line via expo install --fix, adding expo-splash-screen and
expo-system-ui for config plugins the SDK split out of core. Bump
@bacons/apple-targets, react-native-view-shot, and other native modules
to versions compatible with React Native 0.85 and the new architecture.

Remove @react-navigation/* direct dependencies and route their hooks
through expo-router, which is no longer compatible with react-navigation
as of SDK 56. Fix RN 0.85's removed StyleSheet.absoluteFillObject (use
absoluteFill) and react-native-view-shot 5.x's ViewShotRef type across
call sites. Downgrade eslint-plugin-react-hooks v7's new React Compiler
era rules to warnings pending a dedicated refactor, and repair Jest
fallout from the jest-expo/reanimated/worklets/expo-media-library bumps.

Regenerate the committed ios/ and android/ native projects with
`expo prebuild --clean` (required for @bacons/apple-targets 5.x) against
the production release channel, and document that requirement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 11:11:37 +01:00

202 lines
6.0 KiB
TypeScript

import React, { useEffect } from 'react';
import { View, Text, StyleSheet, ActivityIndicator, ViewStyle, TextStyle } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, {
FadeIn,
FadeOut,
useSharedValue,
useAnimatedStyle,
withRepeat,
withTiming,
interpolate,
Easing,
} from 'react-native-reanimated';
import { colors, typography, spacing, radius } from '../../theme';
export type LoadingVariant = 'spinner' | 'shimmer' | 'progress';
export interface LoadingOverlayProps {
/** Whether the loading overlay is visible */
visible: boolean;
/** Visual variant of the loading indicator */
variant?: LoadingVariant;
/** Status text to display */
statusText?: string;
/** Secondary/detail text */
detailText?: string;
/** Progress value (0-100) for 'progress' variant */
progress?: number;
/** Custom spinner color */
spinnerColor?: string;
/** Whether to use full-screen overlay mode */
fullScreen?: boolean;
/** Custom container style */
style?: ViewStyle;
/** Custom text style */
textStyle?: TextStyle;
}
/**
* Flexible loading overlay component supporting multiple variants.
* Used for upload status, loading states, and progress indicators.
*/
export function LoadingOverlay({
visible,
variant = 'spinner',
statusText,
detailText,
progress = 0,
spinnerColor = colors.premium.gold,
fullScreen = false,
style,
textStyle,
}: LoadingOverlayProps) {
const shimmer = useSharedValue(0);
useEffect(() => {
if (visible && variant === 'shimmer') {
shimmer.value = withRepeat(
withTiming(1, { duration: 1500, easing: Easing.inOut(Easing.ease) }),
-1,
false
);
} else {
shimmer.value = 0;
}
}, [visible, variant]);
const shimmerStyle = useAnimatedStyle(() => ({
opacity: interpolate(shimmer.value, [0, 0.5, 1], [0.3, 0.6, 0.3]),
}));
if (!visible) return null;
const renderContent = () => {
switch (variant) {
case 'shimmer':
return (
<Animated.View style={[styles.shimmerContent, shimmerStyle]}>
<ActivityIndicator color={spinnerColor} size="small" />
{statusText && (
<Text style={[styles.statusText, textStyle]}>{statusText}</Text>
)}
{detailText && (
<Text style={styles.detailText}>{detailText}</Text>
)}
</Animated.View>
);
case 'progress':
return (
<View style={styles.progressContent}>
<View style={styles.progressBarContainer}>
<LinearGradient
colors={[colors.premium.gold, colors.premium.goldDark]}
style={[styles.progressBar, { width: `${Math.min(100, Math.max(0, progress))}%` }]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
/>
</View>
{statusText && (
<Text style={[styles.statusText, textStyle]}>{statusText}</Text>
)}
{detailText && (
<Text style={styles.detailText}>{detailText}</Text>
)}
</View>
);
case 'spinner':
default:
return (
<View style={styles.spinnerContent}>
<ActivityIndicator color={spinnerColor} size="large" />
{statusText && (
<Text style={[styles.statusText, textStyle]}>{statusText}</Text>
)}
{detailText && (
<Text style={styles.detailText}>{detailText}</Text>
)}
</View>
);
}
};
if (fullScreen) {
return (
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(200)}
style={[styles.fullScreenContainer, style]}
>
{renderContent()}
</Animated.View>
);
}
return (
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(200)}
style={[styles.container, style]}
>
{renderContent()}
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
padding: spacing.md,
},
fullScreenContainer: {
...StyleSheet.absoluteFill,
backgroundColor: 'rgba(13, 13, 26, 0.85)',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
},
spinnerContent: {
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
},
shimmerContent: {
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
},
progressContent: {
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
width: '100%',
},
statusText: {
...typography.body,
color: colors.textSecondary,
textAlign: 'center',
marginTop: spacing.xs,
},
detailText: {
...typography.caption1,
color: colors.textTertiary,
textAlign: 'center',
},
progressBarContainer: {
width: '80%',
height: 4,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
borderRadius: radius.full,
overflow: 'hidden',
},
progressBar: {
height: '100%',
borderRadius: radius.full,
},
});
export default LoadingOverlay;