WellNuo/services/offlineAwareApi.ts
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

350 lines
11 KiB
TypeScript

/**
* 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<T>(
apiCall: () => Promise<ApiResponse<T>>,
options: {
retry?: boolean;
retryConfig?: RetryConfig;
offlineMessage?: string;
} = {}
): Promise<ApiResponse<T>> {
// 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<typeof api.updateProfile>[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<typeof api.createBeneficiary>[0]) {
return withOfflineCheck(() => api.createBeneficiary(data), {
offlineMessage: 'Cannot add beneficiary while offline',
});
},
async updateWellNuoBeneficiary(id: number, updates: Parameters<typeof api.updateWellNuoBeneficiary>[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<typeof api.updateDeviceMetadata>[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<typeof api.sendInvitation>[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<typeof api.updateNotificationSettings>[0]) {
return withOfflineCheck(() => api.updateNotificationSettings(settings), {
offlineMessage: 'Cannot update notification settings while offline',
});
},
async getNotificationHistory(options?: Parameters<typeof api.getNotificationHistory>[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 };