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>
350 lines
11 KiB
TypeScript
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 };
|