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}
+
+
+ );
+}
+```
+
+### 5. Provide Pull-to-Refresh
+
+```typescript
+
+ }
+/>
+```
+
+## Examples
+
+See `components/examples/OfflineAwareExample.tsx` for complete examples of:
+- Data fetching with offline handling
+- Form mutations with offline detection
+- Error states and retry logic
+- Pull-to-refresh implementation
+
+## Limitations
+
+Due to the API-first architecture, the app cannot:
+- ❌ Cache beneficiary data for offline use
+- ❌ Queue operations for later sync
+- ❌ Work completely offline
+
+However, it provides:
+- ✅ Clear offline state indication
+- ✅ User-friendly error messages
+- ✅ Automatic retry when back online
+- ✅ Graceful degradation
+
+## Future Enhancements
+
+Potential improvements:
+1. **Optimistic updates** - Show UI changes immediately, sync later
+2. **Offline queue** - Queue write operations when offline
+3. **Limited caching** - Cache read-only data (e.g., subscription plans)
+4. **Background sync** - Sync when connection is restored
+
+## Troubleshooting
+
+### Banner not showing
+- Check that `@react-native-community/netinfo` is installed
+- Verify `useNetworkStatus()` hook is being called
+- Test on device (not simulator for network issues)
+
+### API calls not detecting offline
+- Import from `offlineAwareApi` not `api`
+- Check network permissions in app.json
+
+### Retries not working
+- Verify `retry: true` is passed in options
+- Check that operation is async and returns ApiResponse
+
+## Related Files
+
+- `utils/networkStatus.ts` - Network detection utilities
+- `services/offlineAwareApi.ts` - Offline-aware API wrapper
+- `components/OfflineBanner.tsx` - Offline banner UI
+- `hooks/useOfflineAwareData.ts` - Data fetching hooks
+- `components/examples/OfflineAwareExample.tsx` - Usage examples
+- `__tests__/offline/` - Tests
diff --git a/hooks/useOfflineAwareData.ts b/hooks/useOfflineAwareData.ts
new file mode 100644
index 0000000..1b2df94
--- /dev/null
+++ b/hooks/useOfflineAwareData.ts
@@ -0,0 +1,276 @@
+/**
+ * Offline-Aware Data Fetching Hook
+ *
+ * Custom hook that handles data fetching with offline detection,
+ * loading states, error handling, and retry logic.
+ */
+
+import { useState, useEffect, useCallback } from 'react';
+import { useNetworkStatus } from '@/utils/networkStatus';
+import type { ApiResponse, ApiError } from '@/types';
+import { isNetworkError, getNetworkErrorMessage } from '@/services/offlineAwareApi';
+
+interface UseOfflineAwareDataOptions {
+ /**
+ * Skip fetching data initially (manual fetch only)
+ * Default: false
+ */
+ skip?: boolean;
+
+ /**
+ * Refetch data when network comes back online
+ * Default: true
+ */
+ refetchOnReconnect?: boolean;
+
+ /**
+ * Custom error message when offline
+ */
+ offlineMessage?: string;
+
+ /**
+ * Poll interval in milliseconds (0 to disable)
+ * Default: 0
+ */
+ pollInterval?: number;
+}
+
+interface UseOfflineAwareDataReturn {
+ /**
+ * Fetched data (null if not loaded or error)
+ */
+ data: T | null;
+
+ /**
+ * Loading state (true during initial fetch)
+ */
+ loading: boolean;
+
+ /**
+ * Error object (null if no error)
+ */
+ error: ApiError | null;
+
+ /**
+ * Refetch data manually
+ */
+ refetch: () => Promise;
+
+ /**
+ * Is currently refetching (true during manual refetch)
+ */
+ refetching: boolean;
+
+ /**
+ * User-friendly error message
+ */
+ errorMessage: string | null;
+
+ /**
+ * Is the error network-related?
+ */
+ isOfflineError: boolean;
+}
+
+/**
+ * Custom hook for offline-aware data fetching
+ *
+ * @param fetcher - Async function that returns ApiResponse
+ * @param dependencies - Dependencies array (like useEffect)
+ * @param options - Configuration options
+ *
+ * @example
+ * function MyComponent() {
+ * const { data, loading, error, refetch } = useOfflineAwareData(
+ * () => offlineAwareApi.getAllBeneficiaries(),
+ * []
+ * );
+ *
+ * if (loading) return ;
+ * if (error) return ;
+ * return
;
+ * }
+ */
+export function useOfflineAwareData(
+ fetcher: () => Promise>,
+ dependencies: any[] = [],
+ options: UseOfflineAwareDataOptions = {}
+): UseOfflineAwareDataReturn {
+ const {
+ skip = false,
+ refetchOnReconnect = true,
+ offlineMessage,
+ pollInterval = 0,
+ } = options;
+
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(!skip);
+ const [refetching, setRefetching] = useState(false);
+ const [error, setError] = useState(null);
+ const { isOnline } = useNetworkStatus();
+
+ // Fetch data
+ const fetchData = useCallback(
+ async (isRefetch = false) => {
+ if (isRefetch) {
+ setRefetching(true);
+ } else {
+ setLoading(true);
+ }
+ setError(null);
+
+ try {
+ const response = await fetcher();
+
+ if (response.ok && response.data) {
+ setData(response.data);
+ setError(null);
+ } else {
+ setData(null);
+ setError(response.error || { message: 'Unknown error' });
+ }
+ } catch (err) {
+ setData(null);
+ setError({
+ message: err instanceof Error ? err.message : 'Unknown error',
+ code: 'EXCEPTION',
+ });
+ } finally {
+ setLoading(false);
+ setRefetching(false);
+ }
+ },
+ [fetcher]
+ );
+
+ // Initial fetch
+ useEffect(() => {
+ if (!skip) {
+ fetchData(false);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, dependencies);
+
+ // Refetch when network comes back online
+ useEffect(() => {
+ if (refetchOnReconnect && isOnline && error && isNetworkError(error)) {
+ fetchData(true);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isOnline, refetchOnReconnect, error]);
+
+ // Polling (if enabled)
+ useEffect(() => {
+ if (pollInterval > 0 && !skip) {
+ const interval = setInterval(() => {
+ if (isOnline) {
+ fetchData(true);
+ }
+ }, pollInterval);
+
+ return () => clearInterval(interval);
+ }
+ }, [pollInterval, skip, isOnline, fetchData]);
+
+ // Manual refetch
+ const refetch = useCallback(async () => {
+ await fetchData(true);
+ }, [fetchData]);
+
+ // Computed values
+ const isOfflineError = error ? isNetworkError(error) : false;
+ const errorMessage = error
+ ? offlineMessage && isOfflineError
+ ? offlineMessage
+ : getNetworkErrorMessage(error)
+ : null;
+
+ return {
+ data,
+ loading,
+ error,
+ refetch,
+ refetching,
+ errorMessage,
+ isOfflineError,
+ };
+}
+
+/**
+ * Simpler hook for offline-aware mutations (create, update, delete)
+ *
+ * @example
+ * function MyComponent() {
+ * const { mutate, loading, error } = useOfflineAwareMutation(
+ * (data) => offlineAwareApi.createBeneficiary(data)
+ * );
+ *
+ * const handleSubmit = async () => {
+ * const result = await mutate(formData);
+ * if (result.ok) {
+ * // Success
+ * }
+ * };
+ * }
+ */
+export function useOfflineAwareMutation(
+ mutationFn: (variables: TVariables) => Promise>,
+ options: {
+ offlineMessage?: string;
+ onSuccess?: (data: TData) => void;
+ onError?: (error: ApiError) => void;
+ } = {}
+) {
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const { isOnline } = useNetworkStatus();
+
+ const mutate = useCallback(
+ async (variables: TVariables): Promise> => {
+ setLoading(true);
+ setError(null);
+
+ try {
+ const response = await mutationFn(variables);
+
+ if (response.ok && response.data) {
+ options.onSuccess?.(response.data);
+ } else {
+ setError(response.error || { message: 'Unknown error' });
+ options.onError?.(response.error || { message: 'Unknown error' });
+ }
+
+ return response;
+ } catch (err) {
+ const apiError: ApiError = {
+ message: err instanceof Error ? err.message : 'Unknown error',
+ code: 'EXCEPTION',
+ };
+ setError(apiError);
+ options.onError?.(apiError);
+
+ return { ok: false, error: apiError };
+ } finally {
+ setLoading(false);
+ }
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [mutationFn, options.onSuccess, options.onError]
+ );
+
+ const isOfflineError = error ? isNetworkError(error) : false;
+ const errorMessage = error
+ ? options.offlineMessage && isOfflineError
+ ? options.offlineMessage
+ : getNetworkErrorMessage(error)
+ : null;
+
+ return {
+ mutate,
+ loading,
+ error,
+ errorMessage,
+ isOfflineError,
+ isOnline,
+ };
+}
diff --git a/package-lock.json b/package-lock.json
index 13a8def..bd986b5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"@expo/vector-icons": "^15.0.3",
"@orbital-systems/react-native-esp-idf-provisioning": "^0.5.0",
"@react-native-async-storage/async-storage": "^2.2.0",
+ "@react-native-community/netinfo": "^11.5.0",
"@react-native-picker/picker": "^2.11.4",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
@@ -5566,6 +5567,16 @@
"react-native": "^0.0.0-0 || >=0.65 <1.0"
}
},
+ "node_modules/@react-native-community/netinfo": {
+ "version": "11.5.0",
+ "resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-11.5.0.tgz",
+ "integrity": "sha512-9ORfUnMUILDAr+gGesu2XO3i8i9DwPbSHLL9o4vOYIrLM3/bm8k2F3Hcn9/SZ6legqyQS3+PQ7zQDgJatf3fww==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "*",
+ "react-native": ">=0.59"
+ }
+ },
"node_modules/@react-native-picker/picker": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.4.tgz",
diff --git a/package.json b/package.json
index 661a15c..26f0b34 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"@expo/vector-icons": "^15.0.3",
"@orbital-systems/react-native-esp-idf-provisioning": "^0.5.0",
"@react-native-async-storage/async-storage": "^2.2.0",
+ "@react-native-community/netinfo": "^11.5.0",
"@react-native-picker/picker": "^2.11.4",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
diff --git a/services/offlineAwareApi.ts b/services/offlineAwareApi.ts
new file mode 100644
index 0000000..9a7f455
--- /dev/null
+++ b/services/offlineAwareApi.ts
@@ -0,0 +1,349 @@
+/**
+ * Offline-Aware API Wrapper
+ *
+ * Wraps API calls with offline detection and graceful error handling.
+ * Provides consistent error messages and retry logic for network failures.
+ */
+
+import { api } from './api';
+import { isOnline, retryWithBackoff, DEFAULT_RETRY_CONFIG, RetryConfig } from '@/utils/networkStatus';
+import type { ApiResponse, ApiError } from '@/types';
+
+/**
+ * Network-related error codes
+ */
+export const NETWORK_ERROR_CODES = {
+ OFFLINE: 'NETWORK_OFFLINE',
+ TIMEOUT: 'NETWORK_TIMEOUT',
+ UNREACHABLE: 'NETWORK_UNREACHABLE',
+} as const;
+
+/**
+ * User-friendly error messages for network issues
+ */
+export const NETWORK_ERROR_MESSAGES = {
+ 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 error occurred. Please check your connection.',
+} as const;
+
+/**
+ * Check if error is network-related
+ */
+export function isNetworkError(error: ApiError): boolean {
+ if (!error) return false;
+
+ const code = error.code?.toUpperCase() || '';
+ const message = error.message?.toLowerCase() || '';
+
+ return (
+ code === 'NETWORK_ERROR' ||
+ code === 'NETWORK_OFFLINE' ||
+ code === 'NETWORK_TIMEOUT' ||
+ code === 'NETWORK_UNREACHABLE' ||
+ message.includes('network') ||
+ message.includes('offline') ||
+ message.includes('connection') ||
+ message.includes('timeout') ||
+ message.includes('fetch')
+ );
+}
+
+/**
+ * Get user-friendly error message for network errors
+ */
+export function getNetworkErrorMessage(error: ApiError): string {
+ if (!isNetworkError(error)) {
+ return error.message || 'An error occurred';
+ }
+
+ const code = error.code?.toUpperCase();
+ if (code === 'NETWORK_OFFLINE') return NETWORK_ERROR_MESSAGES.OFFLINE;
+ if (code === 'NETWORK_TIMEOUT') return NETWORK_ERROR_MESSAGES.TIMEOUT;
+ if (code === 'NETWORK_UNREACHABLE') return NETWORK_ERROR_MESSAGES.UNREACHABLE;
+
+ return NETWORK_ERROR_MESSAGES.GENERIC;
+}
+
+/**
+ * Wrap an API call with offline detection
+ *
+ * @param apiCall - The API function to call
+ * @param options - Configuration options
+ * @returns Promise with API response
+ *
+ * @example
+ * const response = await withOfflineCheck(() => api.getAllBeneficiaries());
+ * if (!response.ok) {
+ * Alert.alert('Error', getNetworkErrorMessage(response.error));
+ * }
+ */
+export async function withOfflineCheck(
+ apiCall: () => Promise>,
+ options: {
+ retry?: boolean;
+ retryConfig?: RetryConfig;
+ offlineMessage?: string;
+ } = {}
+): Promise> {
+ // Check if online before attempting request
+ const online = await isOnline();
+
+ if (!online) {
+ return {
+ ok: false,
+ error: {
+ message: options.offlineMessage || NETWORK_ERROR_MESSAGES.OFFLINE,
+ code: NETWORK_ERROR_CODES.OFFLINE,
+ },
+ };
+ }
+
+ try {
+ // Execute API call with optional retry
+ if (options.retry) {
+ return await retryWithBackoff(apiCall, options.retryConfig || DEFAULT_RETRY_CONFIG);
+ }
+
+ return await apiCall();
+ } catch (error) {
+ // Convert exception to ApiResponse format
+ const apiError: ApiError = {
+ message: error instanceof Error ? error.message : 'Unknown error',
+ code: 'NETWORK_ERROR',
+ };
+
+ return {
+ ok: false,
+ error: apiError,
+ };
+ }
+}
+
+/**
+ * Offline-aware API service
+ * Wraps the main API service with network detection
+ *
+ * Use this instead of direct `api` imports for better offline handling
+ */
+export const offlineAwareApi = {
+ // ==================== Authentication ====================
+
+ async checkEmail(email: string) {
+ return withOfflineCheck(() => api.checkEmail(email), {
+ retry: true,
+ offlineMessage: 'Cannot verify email while offline',
+ });
+ },
+
+ async requestOTP(email: string) {
+ return withOfflineCheck(() => api.requestOTP(email), {
+ retry: true,
+ offlineMessage: 'Cannot send verification code while offline',
+ });
+ },
+
+ async verifyOTP(email: string, code: string) {
+ return withOfflineCheck(() => api.verifyOTP(email, code), {
+ retry: true,
+ offlineMessage: 'Cannot verify code while offline',
+ });
+ },
+
+ async getProfile() {
+ return withOfflineCheck(() => api.getProfile(), {
+ retry: true,
+ });
+ },
+
+ async updateProfile(updates: Parameters[0]) {
+ return withOfflineCheck(() => api.updateProfile(updates), {
+ offlineMessage: 'Cannot update profile while offline',
+ });
+ },
+
+ async updateProfileAvatar(imageUri: string | null) {
+ return withOfflineCheck(() => api.updateProfileAvatar(imageUri), {
+ offlineMessage: 'Cannot upload avatar while offline',
+ });
+ },
+
+ // ==================== Beneficiaries ====================
+
+ async getAllBeneficiaries() {
+ return withOfflineCheck(() => api.getAllBeneficiaries(), {
+ retry: true,
+ });
+ },
+
+ async getWellNuoBeneficiary(id: number) {
+ return withOfflineCheck(() => api.getWellNuoBeneficiary(id), {
+ retry: true,
+ });
+ },
+
+ async createBeneficiary(data: Parameters[0]) {
+ return withOfflineCheck(() => api.createBeneficiary(data), {
+ offlineMessage: 'Cannot add beneficiary while offline',
+ });
+ },
+
+ async updateWellNuoBeneficiary(id: number, updates: Parameters[1]) {
+ return withOfflineCheck(() => api.updateWellNuoBeneficiary(id, updates), {
+ offlineMessage: 'Cannot update beneficiary while offline',
+ });
+ },
+
+ async updateBeneficiaryAvatar(id: number, imageUri: string | null) {
+ return withOfflineCheck(() => api.updateBeneficiaryAvatar(id, imageUri), {
+ offlineMessage: 'Cannot upload avatar while offline',
+ });
+ },
+
+ async updateBeneficiaryCustomName(id: number, customName: string | null) {
+ return withOfflineCheck(() => api.updateBeneficiaryCustomName(id, customName), {
+ offlineMessage: 'Cannot update name while offline',
+ });
+ },
+
+ async deleteBeneficiary(id: number) {
+ return withOfflineCheck(() => api.deleteBeneficiary(id), {
+ offlineMessage: 'Cannot remove beneficiary while offline',
+ });
+ },
+
+ // ==================== Devices / Sensors ====================
+
+ async getDevicesForBeneficiary(beneficiaryId: string) {
+ return withOfflineCheck(() => api.getDevicesForBeneficiary(beneficiaryId), {
+ retry: true,
+ });
+ },
+
+ async attachDeviceToBeneficiary(beneficiaryId: string, wellId: number, deviceMac: string) {
+ return withOfflineCheck(() => api.attachDeviceToBeneficiary(beneficiaryId, wellId, deviceMac), {
+ offlineMessage: 'Cannot attach sensor while offline',
+ });
+ },
+
+ async updateDeviceMetadata(deviceId: string, updates: Parameters[1]) {
+ return withOfflineCheck(() => api.updateDeviceMetadata(deviceId, updates), {
+ offlineMessage: 'Cannot update sensor settings while offline',
+ });
+ },
+
+ async detachDeviceFromBeneficiary(beneficiaryId: string, deviceId: string) {
+ return withOfflineCheck(() => api.detachDeviceFromBeneficiary(beneficiaryId, deviceId), {
+ offlineMessage: 'Cannot remove sensor while offline',
+ });
+ },
+
+ async getSensorHealthHistory(deviceId: string, timeRange: '24h' | '7d' | '30d' = '24h') {
+ return withOfflineCheck(() => api.getSensorHealthHistory(deviceId, timeRange), {
+ retry: true,
+ });
+ },
+
+ // ==================== Subscriptions ====================
+
+ async cancelSubscription(beneficiaryId: number) {
+ return withOfflineCheck(() => api.cancelSubscription(beneficiaryId), {
+ offlineMessage: 'Cannot cancel subscription while offline',
+ });
+ },
+
+ async reactivateSubscription(beneficiaryId: number) {
+ return withOfflineCheck(() => api.reactivateSubscription(beneficiaryId), {
+ offlineMessage: 'Cannot reactivate subscription while offline',
+ });
+ },
+
+ async getTransactionHistory(beneficiaryId: number, limit = 10) {
+ return withOfflineCheck(() => api.getTransactionHistory(beneficiaryId, limit), {
+ retry: true,
+ });
+ },
+
+ // ==================== Invitations ====================
+
+ async sendInvitation(params: Parameters[0]) {
+ return withOfflineCheck(() => api.sendInvitation(params), {
+ offlineMessage: 'Cannot send invitation while offline',
+ });
+ },
+
+ async getInvitations(beneficiaryId: string) {
+ return withOfflineCheck(() => api.getInvitations(beneficiaryId), {
+ retry: true,
+ });
+ },
+
+ async deleteInvitation(invitationId: string) {
+ return withOfflineCheck(() => api.deleteInvitation(invitationId), {
+ offlineMessage: 'Cannot delete invitation while offline',
+ });
+ },
+
+ async updateInvitation(invitationId: string, role: 'caretaker' | 'guardian') {
+ return withOfflineCheck(() => api.updateInvitation(invitationId, role), {
+ offlineMessage: 'Cannot update invitation while offline',
+ });
+ },
+
+ async acceptInvitation(code: string) {
+ return withOfflineCheck(() => api.acceptInvitation(code), {
+ offlineMessage: 'Cannot accept invitation while offline',
+ });
+ },
+
+ // ==================== Notifications ====================
+
+ async getNotificationSettings() {
+ return withOfflineCheck(() => api.getNotificationSettings(), {
+ retry: true,
+ });
+ },
+
+ async updateNotificationSettings(settings: Parameters[0]) {
+ return withOfflineCheck(() => api.updateNotificationSettings(settings), {
+ offlineMessage: 'Cannot update notification settings while offline',
+ });
+ },
+
+ async getNotificationHistory(options?: Parameters[0]) {
+ return withOfflineCheck(() => api.getNotificationHistory(options), {
+ retry: true,
+ });
+ },
+
+ // ==================== AI Chat (Legacy API) ====================
+
+ async sendMessage(question: string, deploymentId: string) {
+ return withOfflineCheck(() => api.sendMessage(question, deploymentId), {
+ offlineMessage: 'Cannot send message while offline',
+ });
+ },
+
+ // ==================== Equipment Activation ====================
+
+ async activateBeneficiary(beneficiaryId: number, serialNumber: string) {
+ return withOfflineCheck(() => api.activateBeneficiary(beneficiaryId, serialNumber), {
+ offlineMessage: 'Cannot activate equipment while offline',
+ });
+ },
+
+ async updateBeneficiaryEquipmentStatus(
+ id: number,
+ status: 'none' | 'ordered' | 'shipped' | 'delivered'
+ ) {
+ return withOfflineCheck(() => api.updateBeneficiaryEquipmentStatus(id, status), {
+ offlineMessage: 'Cannot update equipment status while offline',
+ });
+ },
+};
+
+/**
+ * Export utility functions
+ */
+export { isNetworkError as isOfflineError, getNetworkErrorMessage as getOfflineErrorMessage };
diff --git a/utils/networkStatus.ts b/utils/networkStatus.ts
new file mode 100644
index 0000000..b08e0cb
--- /dev/null
+++ b/utils/networkStatus.ts
@@ -0,0 +1,192 @@
+/**
+ * Network Status Detection and Monitoring
+ *
+ * Provides utilities for detecting and responding to network connectivity changes.
+ * Used throughout the app for graceful offline mode degradation.
+ */
+
+import NetInfo, { NetInfoState } from '@react-native-community/netinfo';
+import { useEffect, useState, useCallback } from 'react';
+
+/**
+ * Network status type
+ */
+export type NetworkStatus = 'online' | 'offline' | 'unknown';
+
+/**
+ * Get current network status (sync)
+ * Use this for one-time checks
+ */
+export async function getNetworkStatus(): Promise {
+ try {
+ const state = await NetInfo.fetch();
+ return state.isConnected ? 'online' : 'offline';
+ } catch {
+ return 'unknown';
+ }
+}
+
+/**
+ * Check if device is currently online
+ * Use this before making API calls
+ */
+export async function isOnline(): Promise {
+ const status = await getNetworkStatus();
+ return status === 'online';
+}
+
+/**
+ * React hook for network status monitoring
+ *
+ * @example
+ * function MyComponent() {
+ * const { isOnline, isOffline, status } = useNetworkStatus();
+ *
+ * if (isOffline) {
+ * return ;
+ * }
+ * // ...
+ * }
+ */
+export function useNetworkStatus() {
+ const [status, setStatus] = useState('unknown');
+
+ useEffect(() => {
+ // Get initial status
+ getNetworkStatus().then(setStatus);
+
+ // Subscribe to network changes
+ const unsubscribe = NetInfo.addEventListener((state: NetInfoState) => {
+ setStatus(state.isConnected ? 'online' : 'offline');
+ });
+
+ return () => {
+ unsubscribe();
+ };
+ }, []);
+
+ return {
+ status,
+ isOnline: status === 'online',
+ isOffline: status === 'offline',
+ isUnknown: status === 'unknown',
+ };
+}
+
+/**
+ * React hook for online-only callback
+ * Executes callback only when online, otherwise shows error
+ *
+ * @example
+ * function MyComponent() {
+ * const executeOnline = useOnlineOnly();
+ *
+ * const handleSave = () => {
+ * executeOnline(() => {
+ * // This only runs when online
+ * api.saveBeneficiary(data);
+ * }, 'Cannot save while offline');
+ * };
+ * }
+ */
+export function useOnlineOnly() {
+ const { isOnline } = useNetworkStatus();
+
+ return useCallback(
+ async (callback: () => void | Promise, offlineMessage?: string) => {
+ if (!isOnline) {
+ throw new Error(offlineMessage || 'This action requires an internet connection');
+ }
+ return await callback();
+ },
+ [isOnline]
+ );
+}
+
+/**
+ * Retry configuration for API calls
+ */
+export interface RetryConfig {
+ maxAttempts: number;
+ delayMs: number;
+ backoffMultiplier: number;
+}
+
+/**
+ * Default retry configuration
+ */
+export const DEFAULT_RETRY_CONFIG: RetryConfig = {
+ maxAttempts: 3,
+ delayMs: 1000,
+ backoffMultiplier: 2,
+};
+
+/**
+ * Retry an async operation with exponential backoff
+ * Only retries if network is available
+ *
+ * @param operation - Async function to retry
+ * @param config - Retry configuration
+ * @returns Promise with operation result
+ *
+ * @example
+ * const data = await retryWithBackoff(
+ * () => api.getBeneficiaries(),
+ * { maxAttempts: 3, delayMs: 1000, backoffMultiplier: 2 }
+ * );
+ */
+export async function retryWithBackoff(
+ operation: () => Promise,
+ config: RetryConfig = DEFAULT_RETRY_CONFIG
+): Promise {
+ let lastError: Error | undefined;
+ let delay = config.delayMs;
+
+ for (let attempt = 1; attempt <= config.maxAttempts; attempt++) {
+ try {
+ // Check network before retry (after first attempt)
+ if (attempt > 1) {
+ const online = await isOnline();
+ if (!online) {
+ throw new Error('Network unavailable');
+ }
+ }
+
+ return await operation();
+ } catch (error) {
+ lastError = error instanceof Error ? error : new Error('Unknown error');
+
+ // Don't retry on last attempt
+ if (attempt === config.maxAttempts) {
+ break;
+ }
+
+ // Wait before retry with exponential backoff
+ await new Promise(resolve => setTimeout(resolve, delay));
+ delay *= config.backoffMultiplier;
+ }
+ }
+
+ throw lastError || new Error('Operation failed after retries');
+}
+
+/**
+ * React hook for retrying async operations
+ *
+ * @example
+ * function MyComponent() {
+ * const retry = useRetry();
+ *
+ * const loadData = async () => {
+ * const data = await retry(() => api.getBeneficiaries());
+ * setData(data);
+ * };
+ * }
+ */
+export function useRetry(config: RetryConfig = DEFAULT_RETRY_CONFIG) {
+ return useCallback(
+ (operation: () => Promise) => retryWithBackoff(operation, config),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [config.maxAttempts, config.delayMs, config.backoffMultiplier]
+ );
+}