WellNuo/components/examples/OfflineAwareExample.tsx
Sergei 91e677178e Add offline mode graceful degradation
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>
2026-01-31 16:49:15 -08:00

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