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>
305 lines
8.0 KiB
TypeScript
305 lines
8.0 KiB
TypeScript
/**
|
|
* Example: Offline-Aware Component
|
|
*
|
|
* Demonstrates best practices for implementing offline mode graceful degradation.
|
|
* This example shows how to:
|
|
* 1. Use useOfflineAwareData hook for data fetching
|
|
* 2. Display offline banners
|
|
* 3. Handle loading and error states
|
|
* 4. Provide retry functionality
|
|
*
|
|
* NOTE: This is a reference implementation. Copy patterns from this file
|
|
* when adding offline handling to actual screens.
|
|
*/
|
|
|
|
import React from 'react';
|
|
import { View, Text, StyleSheet, FlatList, TouchableOpacity, ActivityIndicator, RefreshControl } from 'react-native';
|
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
import { OfflineBanner } from '@/components/OfflineBanner';
|
|
import { useOfflineAwareData } from '@/hooks/useOfflineAwareData';
|
|
import { offlineAwareApi } from '@/services/offlineAwareApi';
|
|
import { AppColors, Spacing, FontSizes } from '@/constants/theme';
|
|
import type { Beneficiary } from '@/types';
|
|
|
|
/**
|
|
* Example: Beneficiaries List with Offline Handling
|
|
*/
|
|
export function BeneficiariesListExample() {
|
|
// Use offline-aware data hook
|
|
const {
|
|
data: beneficiaries,
|
|
loading,
|
|
error,
|
|
refetch,
|
|
refetching,
|
|
errorMessage,
|
|
isOfflineError,
|
|
} = useOfflineAwareData(
|
|
() => offlineAwareApi.getAllBeneficiaries(),
|
|
[], // Dependencies
|
|
{
|
|
refetchOnReconnect: true, // Auto-refetch when back online
|
|
pollInterval: 0, // Disable polling (set to e.g., 30000 for 30s polling)
|
|
}
|
|
);
|
|
|
|
// Loading state (initial load)
|
|
if (loading) {
|
|
return (
|
|
<SafeAreaView style={styles.container}>
|
|
<OfflineBanner />
|
|
<View style={styles.centerContent}>
|
|
<ActivityIndicator size="large" color={AppColors.primary} />
|
|
<Text style={styles.loadingText}>Loading beneficiaries...</Text>
|
|
</View>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
// Error state (with retry button)
|
|
if (error && !beneficiaries) {
|
|
return (
|
|
<SafeAreaView style={styles.container}>
|
|
<OfflineBanner />
|
|
<View style={styles.centerContent}>
|
|
<Text style={styles.errorTitle}>
|
|
{isOfflineError ? 'You\'re Offline' : 'Something Went Wrong'}
|
|
</Text>
|
|
<Text style={styles.errorMessage}>{errorMessage}</Text>
|
|
<TouchableOpacity style={styles.retryButton} onPress={refetch}>
|
|
<Text style={styles.retryButtonText}>Try Again</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
// Empty state
|
|
if (!beneficiaries || beneficiaries.length === 0) {
|
|
return (
|
|
<SafeAreaView style={styles.container}>
|
|
<OfflineBanner />
|
|
<View style={styles.centerContent}>
|
|
<Text style={styles.emptyTitle}>No Beneficiaries</Text>
|
|
<Text style={styles.emptyMessage}>Add someone to start monitoring</Text>
|
|
</View>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
// Success state - show data
|
|
return (
|
|
<SafeAreaView style={styles.container}>
|
|
{/* Offline banner - auto-shows when offline */}
|
|
<OfflineBanner />
|
|
|
|
{/* List with pull-to-refresh */}
|
|
<FlatList
|
|
data={beneficiaries}
|
|
keyExtractor={(item) => item.id.toString()}
|
|
renderItem={({ item }) => (
|
|
<View style={styles.listItem}>
|
|
<Text style={styles.itemName}>{item.displayName}</Text>
|
|
<Text style={styles.itemStatus}>{item.status}</Text>
|
|
</View>
|
|
)}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={refetching}
|
|
onRefresh={refetch}
|
|
tintColor={AppColors.primary}
|
|
/>
|
|
}
|
|
contentContainerStyle={styles.listContent}
|
|
/>
|
|
|
|
{/* Show error banner if refetch fails (but we still have cached data) */}
|
|
{error && (
|
|
<View style={styles.errorBanner}>
|
|
<Text style={styles.errorBannerText}>{errorMessage}</Text>
|
|
<TouchableOpacity onPress={refetch}>
|
|
<Text style={styles.errorBannerRetry}>Retry</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
)}
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Example: Form with Offline Mutation
|
|
*/
|
|
import { useOfflineAwareMutation } from '@/hooks/useOfflineAwareData';
|
|
import { Alert } from 'react-native';
|
|
|
|
export function CreateBeneficiaryExample() {
|
|
const [name, setName] = React.useState('');
|
|
|
|
const { mutate, loading, errorMessage, isOfflineError } = useOfflineAwareMutation(
|
|
(data: { name: string }) => offlineAwareApi.createBeneficiary(data),
|
|
{
|
|
offlineMessage: 'Cannot create beneficiary while offline',
|
|
onSuccess: (beneficiary) => {
|
|
Alert.alert('Success', `Added ${beneficiary.name}`);
|
|
setName('');
|
|
},
|
|
onError: (error) => {
|
|
Alert.alert('Error', errorMessage || 'Failed to add beneficiary');
|
|
},
|
|
}
|
|
);
|
|
|
|
const handleSubmit = async () => {
|
|
if (!name.trim()) {
|
|
Alert.alert('Error', 'Please enter a name');
|
|
return;
|
|
}
|
|
|
|
await mutate({ name: name.trim() });
|
|
};
|
|
|
|
return (
|
|
<SafeAreaView style={styles.container}>
|
|
<OfflineBanner />
|
|
|
|
<View style={styles.formContainer}>
|
|
<Text style={styles.formLabel}>Beneficiary Name</Text>
|
|
{/* Add TextInput here */}
|
|
|
|
{errorMessage && (
|
|
<Text style={styles.formError}>{errorMessage}</Text>
|
|
)}
|
|
|
|
<TouchableOpacity
|
|
style={[styles.submitButton, loading && styles.submitButtonDisabled]}
|
|
onPress={handleSubmit}
|
|
disabled={loading}
|
|
>
|
|
{loading ? (
|
|
<ActivityIndicator color="#fff" />
|
|
) : (
|
|
<Text style={styles.submitButtonText}>Add Beneficiary</Text>
|
|
)}
|
|
</TouchableOpacity>
|
|
</View>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: AppColors.background,
|
|
},
|
|
centerContent: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
padding: Spacing.lg,
|
|
},
|
|
loadingText: {
|
|
marginTop: Spacing.md,
|
|
fontSize: FontSizes.md,
|
|
color: AppColors.textSecondary,
|
|
},
|
|
errorTitle: {
|
|
fontSize: FontSizes.xl,
|
|
fontWeight: '600',
|
|
color: AppColors.textPrimary,
|
|
marginBottom: Spacing.sm,
|
|
},
|
|
errorMessage: {
|
|
fontSize: FontSizes.md,
|
|
color: AppColors.textSecondary,
|
|
textAlign: 'center',
|
|
marginBottom: Spacing.lg,
|
|
},
|
|
retryButton: {
|
|
backgroundColor: AppColors.primary,
|
|
paddingHorizontal: Spacing.lg,
|
|
paddingVertical: Spacing.md,
|
|
borderRadius: 8,
|
|
},
|
|
retryButtonText: {
|
|
color: '#fff',
|
|
fontSize: FontSizes.md,
|
|
fontWeight: '600',
|
|
},
|
|
emptyTitle: {
|
|
fontSize: FontSizes.xl,
|
|
fontWeight: '600',
|
|
color: AppColors.textPrimary,
|
|
marginBottom: Spacing.sm,
|
|
},
|
|
emptyMessage: {
|
|
fontSize: FontSizes.md,
|
|
color: AppColors.textSecondary,
|
|
},
|
|
listContent: {
|
|
padding: Spacing.md,
|
|
},
|
|
listItem: {
|
|
backgroundColor: AppColors.backgroundSecondary,
|
|
padding: Spacing.md,
|
|
borderRadius: 8,
|
|
marginBottom: Spacing.sm,
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
},
|
|
itemName: {
|
|
fontSize: FontSizes.md,
|
|
fontWeight: '600',
|
|
color: AppColors.textPrimary,
|
|
},
|
|
itemStatus: {
|
|
fontSize: FontSizes.sm,
|
|
color: AppColors.textSecondary,
|
|
},
|
|
errorBanner: {
|
|
backgroundColor: AppColors.danger,
|
|
padding: Spacing.md,
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
},
|
|
errorBannerText: {
|
|
flex: 1,
|
|
color: '#fff',
|
|
fontSize: FontSizes.sm,
|
|
},
|
|
errorBannerRetry: {
|
|
color: '#fff',
|
|
fontSize: FontSizes.sm,
|
|
fontWeight: '600',
|
|
},
|
|
formContainer: {
|
|
padding: Spacing.lg,
|
|
},
|
|
formLabel: {
|
|
fontSize: FontSizes.md,
|
|
fontWeight: '600',
|
|
color: AppColors.textPrimary,
|
|
marginBottom: Spacing.sm,
|
|
},
|
|
formError: {
|
|
fontSize: FontSizes.sm,
|
|
color: AppColors.danger,
|
|
marginTop: Spacing.sm,
|
|
},
|
|
submitButton: {
|
|
backgroundColor: AppColors.primary,
|
|
padding: Spacing.md,
|
|
borderRadius: 8,
|
|
alignItems: 'center',
|
|
marginTop: Spacing.lg,
|
|
},
|
|
submitButtonDisabled: {
|
|
opacity: 0.6,
|
|
},
|
|
submitButtonText: {
|
|
color: '#fff',
|
|
fontSize: FontSizes.md,
|
|
fontWeight: '600',
|
|
},
|
|
});
|