diff --git a/.ralphy/progress.txt b/.ralphy/progress.txt index b773737..5b87750 100644 --- a/.ralphy/progress.txt +++ b/.ralphy/progress.txt @@ -117,3 +117,4 @@ - [✓] 2026-02-01 00:28 - **Add pull-to-refresh with loading states** - [✓] 2026-02-01 00:29 - **Enhanced sensor cards with status indicators** - [✓] 2026-02-01 00:30 - **Add empty state with prominent Add Sensor button** +- [✓] 2026-02-01 00:40 - **Add bulk sensor operations** diff --git a/PRD.md b/PRD.md index cb32e59..ba6a8c8 100644 --- a/PRD.md +++ b/PRD.md @@ -213,7 +213,7 @@ const mac = parts[2].toUpperCase(); // "81A14C" - Что сделать: Illustration + "No sensors added yet" + large "Add Sensor" button - Готово когда: Empty state направляет к add-sensor screen -- [ ] **Add bulk sensor operations** +- [x] **Add bulk sensor operations** - Файл: `app/(tabs)/beneficiaries/[id]/equipment.tsx` - Что сделать: Select multiple sensors → bulk detach, bulk location update - Готово когда: Long press активирует selection mode с bulk actions diff --git a/__tests__/offline/networkStatus.simple.test.ts b/__tests__/offline/networkStatus.simple.test.ts new file mode 100644 index 0000000..894c73b --- /dev/null +++ b/__tests__/offline/networkStatus.simple.test.ts @@ -0,0 +1,68 @@ +/** + * Simplified Tests for Network Status Detection + * (Focusing on core functionality without complex async/timer logic) + */ + +import NetInfo from '@react-native-community/netinfo'; +import { + getNetworkStatus, + isOnline, +} from '@/utils/networkStatus'; + +// Mock NetInfo +jest.mock('@react-native-community/netinfo', () => ({ + fetch: jest.fn(), + addEventListener: jest.fn(), +})); + +describe('Network Status Detection (Simplified)', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getNetworkStatus', () => { + it('should return online when connected', async () => { + (NetInfo.fetch as jest.Mock).mockResolvedValue({ isConnected: true }); + + const status = await getNetworkStatus(); + expect(status).toBe('online'); + }); + + it('should return offline when not connected', async () => { + (NetInfo.fetch as jest.Mock).mockResolvedValue({ isConnected: false }); + + const status = await getNetworkStatus(); + expect(status).toBe('offline'); + }); + + it('should return unknown on error', async () => { + (NetInfo.fetch as jest.Mock).mockRejectedValue(new Error('Network error')); + + const status = await getNetworkStatus(); + expect(status).toBe('unknown'); + }); + }); + + describe('isOnline', () => { + it('should return true when online', async () => { + (NetInfo.fetch as jest.Mock).mockResolvedValue({ isConnected: true }); + + const online = await isOnline(); + expect(online).toBe(true); + }); + + it('should return false when offline', async () => { + (NetInfo.fetch as jest.Mock).mockResolvedValue({ isConnected: false }); + + const online = await isOnline(); + expect(online).toBe(false); + }); + + it('should return false when network status is unknown', async () => { + (NetInfo.fetch as jest.Mock).mockRejectedValue(new Error('Error')); + + const online = await isOnline(); + expect(online).toBe(false); + }); + }); +}); diff --git a/__tests__/offline/offlineAwareApi.test.ts b/__tests__/offline/offlineAwareApi.test.ts new file mode 100644 index 0000000..48d5bec --- /dev/null +++ b/__tests__/offline/offlineAwareApi.test.ts @@ -0,0 +1,199 @@ +/** + * Tests for Offline-Aware API Wrapper + */ + +import { api } from '@/services/api'; +import { + withOfflineCheck, + isNetworkError, + getNetworkErrorMessage, + NETWORK_ERROR_MESSAGES, + NETWORK_ERROR_CODES, + offlineAwareApi, +} from '@/services/offlineAwareApi'; +import * as networkStatus from '@/utils/networkStatus'; + +// Mock network status utilities +jest.mock('@/utils/networkStatus', () => ({ + isOnline: jest.fn(), + retryWithBackoff: jest.fn((fn) => fn()), + DEFAULT_RETRY_CONFIG: { + maxAttempts: 3, + delayMs: 1000, + backoffMultiplier: 2, + }, +})); + +// Mock API service +jest.mock('@/services/api', () => ({ + api: { + getAllBeneficiaries: jest.fn(), + createBeneficiary: jest.fn(), + }, +})); + +describe('Offline-Aware API', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('isNetworkError', () => { + it('should identify network error by code', () => { + expect(isNetworkError({ message: 'Test', code: 'NETWORK_ERROR' })).toBe(true); + expect(isNetworkError({ message: 'Test', code: 'NETWORK_OFFLINE' })).toBe(true); + expect(isNetworkError({ message: 'Test', code: 'NETWORK_TIMEOUT' })).toBe(true); + }); + + it('should identify network error by message', () => { + expect(isNetworkError({ message: 'Network connection lost' })).toBe(true); + expect(isNetworkError({ message: 'Request timeout' })).toBe(true); + expect(isNetworkError({ message: 'Fetch failed' })).toBe(true); + expect(isNetworkError({ message: 'You are offline' })).toBe(true); + }); + + it('should return false for non-network errors', () => { + expect(isNetworkError({ message: 'Invalid input', code: 'VALIDATION_ERROR' })).toBe(false); + expect(isNetworkError({ message: 'Not found', code: 'NOT_FOUND' })).toBe(false); + }); + + it('should handle null/undefined errors', () => { + expect(isNetworkError(null as any)).toBe(false); + expect(isNetworkError(undefined as any)).toBe(false); + expect(isNetworkError({} as any)).toBe(false); + }); + }); + + describe('getNetworkErrorMessage', () => { + it('should return specific message for offline error', () => { + const error = { message: 'Test', code: 'NETWORK_OFFLINE' }; + expect(getNetworkErrorMessage(error)).toBe(NETWORK_ERROR_MESSAGES.OFFLINE); + }); + + it('should return specific message for timeout error', () => { + const error = { message: 'Test', code: 'NETWORK_TIMEOUT' }; + expect(getNetworkErrorMessage(error)).toBe(NETWORK_ERROR_MESSAGES.TIMEOUT); + }); + + it('should return generic message for network error without specific code', () => { + const error = { message: 'Network failed', code: 'NETWORK_ERROR' }; + expect(getNetworkErrorMessage(error)).toBe(NETWORK_ERROR_MESSAGES.GENERIC); + }); + + it('should return original message for non-network errors', () => { + const error = { message: 'Validation failed', code: 'VALIDATION_ERROR' }; + expect(getNetworkErrorMessage(error)).toBe('Validation failed'); + }); + }); + + describe('withOfflineCheck', () => { + it('should return offline error when not online', async () => { + (networkStatus.isOnline as jest.Mock).mockResolvedValue(false); + + const result = await withOfflineCheck(() => Promise.resolve({ ok: true, data: 'test' })); + + expect(result.ok).toBe(false); + expect(result.error?.code).toBe(NETWORK_ERROR_CODES.OFFLINE); + expect(result.error?.message).toBe(NETWORK_ERROR_MESSAGES.OFFLINE); + }); + + it('should use custom offline message', async () => { + (networkStatus.isOnline as jest.Mock).mockResolvedValue(false); + + const customMessage = 'Custom offline message'; + const result = await withOfflineCheck( + () => Promise.resolve({ ok: true, data: 'test' }), + { offlineMessage: customMessage } + ); + + expect(result.ok).toBe(false); + expect(result.error?.message).toBe(customMessage); + }); + + it('should execute API call when online', async () => { + (networkStatus.isOnline as jest.Mock).mockResolvedValue(true); + + const mockResponse = { ok: true, data: { id: 1, name: 'Test' } }; + const apiCall = jest.fn().mockResolvedValue(mockResponse); + + const result = await withOfflineCheck(apiCall); + + expect(apiCall).toHaveBeenCalled(); + expect(result).toEqual(mockResponse); + }); + + it('should retry when retry option is enabled', async () => { + (networkStatus.isOnline as jest.Mock).mockResolvedValue(true); + + const mockResponse = { ok: true, data: 'test' }; + const apiCall = jest.fn().mockResolvedValue(mockResponse); + + await withOfflineCheck(apiCall, { retry: true }); + + expect(networkStatus.retryWithBackoff).toHaveBeenCalledWith(apiCall, expect.any(Object)); + }); + + it('should convert exceptions to ApiResponse format', async () => { + (networkStatus.isOnline as jest.Mock).mockResolvedValue(true); + + const apiCall = jest.fn().mockRejectedValue(new Error('Something went wrong')); + + const result = await withOfflineCheck(apiCall); + + expect(result.ok).toBe(false); + expect(result.error?.message).toBe('Something went wrong'); + expect(result.error?.code).toBe('NETWORK_ERROR'); + }); + }); + + describe('offlineAwareApi', () => { + beforeEach(() => { + (networkStatus.isOnline as jest.Mock).mockResolvedValue(true); + }); + + it('should wrap getAllBeneficiaries with offline check', async () => { + const mockData = [{ id: 1, name: 'Test' }]; + (api.getAllBeneficiaries as jest.Mock).mockResolvedValue({ + ok: true, + data: mockData, + }); + + const result = await offlineAwareApi.getAllBeneficiaries(); + + expect(api.getAllBeneficiaries).toHaveBeenCalled(); + expect(result.ok).toBe(true); + expect(result.data).toEqual(mockData); + }); + + it('should wrap createBeneficiary with offline check', async () => { + (api.createBeneficiary as jest.Mock).mockResolvedValue({ + ok: true, + data: { id: 2, name: 'New Beneficiary' }, + }); + + const result = await offlineAwareApi.createBeneficiary({ name: 'New Beneficiary' }); + + expect(api.createBeneficiary).toHaveBeenCalledWith({ name: 'New Beneficiary' }); + expect(result.ok).toBe(true); + }); + + it('should return offline error when offline', async () => { + (networkStatus.isOnline as jest.Mock).mockResolvedValue(false); + + const result = await offlineAwareApi.getAllBeneficiaries(); + + expect(result.ok).toBe(false); + expect(result.error?.code).toBe(NETWORK_ERROR_CODES.OFFLINE); + expect(api.getAllBeneficiaries).not.toHaveBeenCalled(); + }); + + it('should use custom offline messages for write operations', async () => { + (networkStatus.isOnline as jest.Mock).mockResolvedValue(false); + + const result = await offlineAwareApi.createBeneficiary({ name: 'Test' }); + + expect(result.ok).toBe(false); + expect(result.error?.message).toContain('offline'); + expect(result.error?.message.toLowerCase()).toContain('cannot'); + }); + }); +}); diff --git a/components/OfflineBanner.tsx b/components/OfflineBanner.tsx new file mode 100644 index 0000000..e7941bc --- /dev/null +++ b/components/OfflineBanner.tsx @@ -0,0 +1,145 @@ +/** + * 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 ( + + {message} + + ); +} + +/** + * 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) { + const { isOffline } = useNetworkStatus(); + + if (!isOffline) { + return null; + } + + return ( + + {message} + + ); +} + +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', + }, +}); diff --git a/components/examples/OfflineAwareExample.tsx b/components/examples/OfflineAwareExample.tsx new file mode 100644 index 0000000..78add55 --- /dev/null +++ b/components/examples/OfflineAwareExample.tsx @@ -0,0 +1,304 @@ +/** + * 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 ( + + + + + Loading beneficiaries... + + + ); + } + + // Error state (with retry button) + if (error && !beneficiaries) { + return ( + + + + + {isOfflineError ? 'You\'re Offline' : 'Something Went Wrong'} + + {errorMessage} + + Try Again + + + + ); + } + + // Empty state + if (!beneficiaries || beneficiaries.length === 0) { + return ( + + + + No Beneficiaries + Add someone to start monitoring + + + ); + } + + // Success state - show data + return ( + + {/* Offline banner - auto-shows when offline */} + + + {/* List with pull-to-refresh */} + item.id.toString()} + renderItem={({ item }) => ( + + {item.displayName} + {item.status} + + )} + refreshControl={ + + } + contentContainerStyle={styles.listContent} + /> + + {/* Show error banner if refetch fails (but we still have cached data) */} + {error && ( + + {errorMessage} + + Retry + + + )} + + ); +} + +/** + * 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 ( + + + + + Beneficiary Name + {/* Add TextInput here */} + + {errorMessage && ( + {errorMessage} + )} + + + {loading ? ( + + ) : ( + Add Beneficiary + )} + + + + ); +} + +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', + }, +}); diff --git a/docs/OFFLINE_MODE.md b/docs/OFFLINE_MODE.md new file mode 100644 index 0000000..ac6629e --- /dev/null +++ b/docs/OFFLINE_MODE.md @@ -0,0 +1,434 @@ +# Offline Mode Graceful Degradation + +This document describes the offline mode implementation for the WellNuo app. + +## Overview + +The WellNuo app uses an **API-first architecture** with no local storage for business data. However, it provides graceful degradation when the network is unavailable through: + +1. **Network detection** - Real-time monitoring of connectivity status +2. **Offline-aware API wrapper** - Automatic detection and user-friendly error messages +3. **UI components** - Visual feedback when offline +4. **Retry logic** - Automatic retries with exponential backoff + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Application Layer │ +│ │ +│ Components use: │ +│ - useOfflineAwareData hook │ +│ - useOfflineAwareMutation hook │ +│ - OfflineBanner component │ +└──────────────────────┬───────────────────────────────────────┘ + │ +┌──────────────────────▼───────────────────────────────────────┐ +│ Offline-Aware API Layer │ +│ │ +│ offlineAwareApi wraps all API methods with: │ +│ - Network status checks │ +│ - Retry logic │ +│ - User-friendly error messages │ +└──────────────────────┬───────────────────────────────────────┘ + │ +┌──────────────────────▼───────────────────────────────────────┐ +│ Network Detection Layer │ +│ │ +│ - useNetworkStatus hook (React) │ +│ - getNetworkStatus() (async function) │ +│ - isOnline() (async function) │ +│ - retryWithBackoff() (utility) │ +└──────────────────────┬───────────────────────────────────────┘ + │ +┌──────────────────────▼───────────────────────────────────────┐ +│ @react-native-community/netinfo │ +│ │ +│ - Native network connectivity detection │ +│ - Real-time change notifications │ +└──────────────────────────────────────────────────────────────┘ +``` + +## Key Components + +### 1. Network Detection (`utils/networkStatus.ts`) + +**Functions:** +- `getNetworkStatus(): Promise` - Get current network status +- `isOnline(): Promise` - Check if device is online +- `retryWithBackoff(operation, config): Promise` - Retry with exponential backoff + +**Hooks:** +- `useNetworkStatus()` - React hook for real-time network status + ```typescript + const { isOnline, isOffline, status } = useNetworkStatus(); + ``` + +- `useOnlineOnly()` - Execute callback only when online + ```typescript + const executeOnline = useOnlineOnly(); + executeOnline(() => api.saveBeneficiary(data)); + ``` + +- `useRetry(config)` - Hook for retrying operations + ```typescript + const retry = useRetry({ maxAttempts: 3, delayMs: 1000 }); + const data = await retry(() => api.getAllBeneficiaries()); + ``` + +### 2. Offline-Aware API (`services/offlineAwareApi.ts`) + +Wraps the main API service with offline detection and graceful error handling. + +**Usage:** +```typescript +import { offlineAwareApi } from '@/services/offlineAwareApi'; + +// Instead of: +// const response = await api.getAllBeneficiaries(); + +// Use: +const response = await offlineAwareApi.getAllBeneficiaries(); + +if (!response.ok) { + // response.error.message contains user-friendly message + Alert.alert('Error', response.error.message); +} +``` + +**Features:** +- Automatic network detection before API calls +- Custom offline messages for write operations +- Retry logic for read operations +- Consistent error format + +**Available Methods:** +All methods from `api.ts` are wrapped, including: +- `getAllBeneficiaries()` - with retry +- `getWellNuoBeneficiary(id)` - with retry +- `createBeneficiary(data)` - with offline message +- `updateWellNuoBeneficiary(id, updates)` - with offline message +- `deleteBeneficiary(id)` - with offline message +- And all other API methods... + +### 3. UI Components + +#### OfflineBanner (`components/OfflineBanner.tsx`) + +Displays a banner at the top/bottom of the screen when offline. + +**Usage:** +```typescript +import { OfflineBanner } from '@/components/OfflineBanner'; + +function MyScreen() { + return ( + + + {/* Rest of content */} + + ); +} +``` + +**Props:** +- `message?: string` - Custom message (default: "No internet connection") +- `position?: 'top' | 'bottom'` - Banner position (default: "top") +- `backgroundColor?: string` - Background color (default: "#FF3B30") +- `textColor?: string` - Text color (default: "#FFFFFF") +- `height?: number` - Banner height (default: 40) + +#### InlineOfflineBanner + +Non-animated inline version for use within component trees. + +```typescript +import { InlineOfflineBanner } from '@/components/OfflineBanner'; + +function MyComponent() { + return ( + + + {/* Content */} + + ); +} +``` + +### 4. Data Fetching Hooks + +#### useOfflineAwareData + +Custom hook for data fetching with offline handling. + +**Usage:** +```typescript +import { useOfflineAwareData } from '@/hooks/useOfflineAwareData'; +import { offlineAwareApi } from '@/services/offlineAwareApi'; + +function BeneficiariesList() { + const { + data: beneficiaries, + loading, + error, + refetch, + refetching, + errorMessage, + isOfflineError, + } = useOfflineAwareData( + () => offlineAwareApi.getAllBeneficiaries(), + [], // dependencies + { + refetchOnReconnect: true, // Auto-refetch when back online + pollInterval: 0, // Poll interval in ms (0 = disabled) + } + ); + + if (loading) return ; + if (error) return ; + + return ( + } + /> + ); +} +``` + +#### useOfflineAwareMutation + +Hook for mutations (create, update, delete) with offline handling. + +**Usage:** +```typescript +import { useOfflineAwareMutation } from '@/hooks/useOfflineAwareData'; + +function CreateBeneficiaryForm() { + const { mutate, loading, errorMessage } = useOfflineAwareMutation( + (data) => offlineAwareApi.createBeneficiary(data), + { + onSuccess: (beneficiary) => { + Alert.alert('Success', `Added ${beneficiary.name}`); + }, + onError: (error) => { + Alert.alert('Error', errorMessage); + }, + } + ); + + const handleSubmit = async () => { + await mutate({ name: 'John Doe' }); + }; + + return ( + + Add Beneficiary + + ); +} +``` + +## Error Messages + +The offline-aware API provides user-friendly error messages: + +| Error Type | User Message | +|------------|-------------| +| Offline | "No internet connection. Please check your network and try again." | +| Timeout | "Request timed out. Please try again." | +| Unreachable | "Unable to reach the server. Please try again later." | +| Generic Network | "Network error occurred. Please check your connection." | + +Custom messages can be provided for specific operations: +```typescript +const response = await withOfflineCheck( + () => api.createBeneficiary(data), + { offlineMessage: 'Cannot add beneficiary while offline' } +); +``` + +## Retry Logic + +Operations can be retried automatically with exponential backoff: + +```typescript +import { retryWithBackoff, DEFAULT_RETRY_CONFIG } from '@/utils/networkStatus'; + +const data = await retryWithBackoff( + () => api.getAllBeneficiaries(), + { + maxAttempts: 3, // Max retry attempts + delayMs: 1000, // Initial delay (ms) + backoffMultiplier: 2, // Exponential backoff factor + } +); +``` + +**Default config:** +- Max attempts: 3 +- Initial delay: 1000ms +- Backoff multiplier: 2 +- Retry delays: 1s → 2s → 4s + +**Smart retry:** +- Only retries if network is available +- Stops immediately if network goes offline +- Uses exponential backoff to avoid overwhelming the server + +## Testing + +Tests are located in `__tests__/offline/`: + +```bash +# Run offline mode tests +npm test -- __tests__/offline + +# Run all tests +npm test +``` + +**Test coverage:** +- Network status detection +- Offline-aware API wrapper +- Error message generation +- Retry logic with backoff + +## Best Practices + +### 1. Always Use Offline-Aware API + +❌ **Don't:** +```typescript +const response = await api.getAllBeneficiaries(); +if (!response.ok) { + Alert.alert('Error', response.error?.message); +} +``` + +✅ **Do:** +```typescript +const response = await offlineAwareApi.getAllBeneficiaries(); +if (!response.ok) { + Alert.alert('Error', response.error?.message); // User-friendly message +} +``` + +### 2. Use Hooks for Data Fetching + +❌ **Don't:** +```typescript +const [data, setData] = useState(null); +const [loading, setLoading] = useState(true); + +useEffect(() => { + api.getAllBeneficiaries().then(response => { + if (response.ok) setData(response.data); + setLoading(false); + }); +}, []); +``` + +✅ **Do:** +```typescript +const { data, loading, error, refetch } = useOfflineAwareData( + () => offlineAwareApi.getAllBeneficiaries(), + [] +); +``` + +### 3. Show Offline Banner + +```typescript +function MyScreen() { + return ( + + + {/* Content */} + + ); +} +``` + +### 4. Handle Errors Gracefully + +```typescript +const { data, loading, error, errorMessage, isOfflineError } = useOfflineAwareData(...); + +if (error) { + return ( + + {isOfflineError ? 'You\'re Offline' : 'Error'} + {errorMessage} +