Implements comprehensive offline handling for API-first architecture: Network Detection: - Real-time connectivity monitoring via @react-native-community/netinfo - useNetworkStatus hook for React components - Utility functions: getNetworkStatus(), isOnline() - Retry logic with exponential backoff Offline-Aware API Layer: - Wraps all API methods with network detection - User-friendly error messages for offline states - Automatic retries for read operations - Custom offline messages for write operations UI Components: - OfflineBanner: Animated banner at top/bottom - InlineOfflineBanner: Non-animated inline version - Auto-shows/hides based on network status Data Fetching Hooks: - useOfflineAwareData: Hook for data fetching with offline handling - useOfflineAwareMutation: Hook for create/update/delete operations - Auto-refetch when network returns - Optional polling support Error Handling: - Consistent error messages across app - Network error detection - Retry functionality with user feedback Tests: - Network status detection tests - Offline-aware API wrapper tests - 23 passing tests with full coverage Documentation: - Complete offline mode guide (docs/OFFLINE_MODE.md) - Usage examples (components/examples/OfflineAwareExample.tsx) - Best practices and troubleshooting 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
146 lines
3.2 KiB
TypeScript
146 lines
3.2 KiB
TypeScript
/**
|
|
* Offline Banner Component
|
|
*
|
|
* Displays a banner at the top of the screen when the device is offline.
|
|
* Automatically shows/hides based on network connectivity.
|
|
*/
|
|
|
|
import React from 'react';
|
|
import { StyleSheet, Text, View } from 'react-native';
|
|
import Animated, {
|
|
useAnimatedStyle,
|
|
withTiming,
|
|
useSharedValue,
|
|
withSequence,
|
|
} from 'react-native-reanimated';
|
|
import { useNetworkStatus } from '@/utils/networkStatus';
|
|
|
|
interface OfflineBannerProps {
|
|
/**
|
|
* Custom message to display when offline
|
|
* Default: "No internet connection"
|
|
*/
|
|
message?: string;
|
|
|
|
/**
|
|
* Position of the banner
|
|
* Default: "top"
|
|
*/
|
|
position?: 'top' | 'bottom';
|
|
|
|
/**
|
|
* Background color
|
|
* Default: "#FF3B30" (red)
|
|
*/
|
|
backgroundColor?: string;
|
|
|
|
/**
|
|
* Text color
|
|
* Default: "#FFFFFF" (white)
|
|
*/
|
|
textColor?: string;
|
|
|
|
/**
|
|
* Height of the banner
|
|
* Default: 40
|
|
*/
|
|
height?: number;
|
|
}
|
|
|
|
export function OfflineBanner({
|
|
message = 'No internet connection',
|
|
position = 'top',
|
|
backgroundColor = '#FF3B30',
|
|
textColor = '#FFFFFF',
|
|
height = 40,
|
|
}: OfflineBannerProps) {
|
|
const { isOffline } = useNetworkStatus();
|
|
const translateY = useSharedValue(position === 'top' ? -height : height);
|
|
|
|
// Animate banner in/out based on network status
|
|
React.useEffect(() => {
|
|
if (isOffline) {
|
|
// Slide in with slight bounce
|
|
translateY.value = withSequence(
|
|
withTiming(0, { duration: 300 }),
|
|
withTiming(-2, { duration: 100 }),
|
|
withTiming(0, { duration: 100 })
|
|
);
|
|
} else {
|
|
// Slide out
|
|
translateY.value = withTiming(position === 'top' ? -height : height, {
|
|
duration: 200,
|
|
});
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [isOffline, position, height]);
|
|
|
|
const animatedStyle = useAnimatedStyle(() => ({
|
|
transform: [{ translateY: translateY.value }],
|
|
}));
|
|
|
|
return (
|
|
<Animated.View
|
|
style={[
|
|
styles.container,
|
|
{
|
|
backgroundColor,
|
|
height,
|
|
[position]: 0,
|
|
},
|
|
animatedStyle,
|
|
]}
|
|
>
|
|
<Text style={[styles.text, { color: textColor }]}>{message}</Text>
|
|
</Animated.View>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Inline Offline Banner
|
|
* Displays within the component tree (not positioned absolutely)
|
|
* Use this for inline offline indicators
|
|
*/
|
|
export function InlineOfflineBanner({
|
|
message = 'No internet connection',
|
|
backgroundColor = '#FF3B30',
|
|
textColor = '#FFFFFF',
|
|
}: Omit<OfflineBannerProps, 'position' | 'height'>) {
|
|
const { isOffline } = useNetworkStatus();
|
|
|
|
if (!isOffline) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<View style={[styles.inlineContainer, { backgroundColor }]}>
|
|
<Text style={[styles.text, { color: textColor }]}>{message}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
position: 'absolute',
|
|
left: 0,
|
|
right: 0,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
zIndex: 9999,
|
|
paddingHorizontal: 16,
|
|
},
|
|
inlineContainer: {
|
|
paddingVertical: 12,
|
|
paddingHorizontal: 16,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
marginBottom: 16,
|
|
borderRadius: 8,
|
|
},
|
|
text: {
|
|
fontSize: 14,
|
|
fontWeight: '600',
|
|
textAlign: 'center',
|
|
},
|
|
});
|