feat: add expo mobile application source code
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
import { Platform } from 'react-native';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import Constants from 'expo-constants';
|
||||
import { storage } from '../utils/storage';
|
||||
|
||||
/**
|
||||
* Dynamic API Base URL
|
||||
*/
|
||||
/**
|
||||
* Dynamic API Base URL
|
||||
*
|
||||
* PRIORITY:
|
||||
* 1. MANUAL_API_IP (Explicit override for development)
|
||||
* 2. Cached Remote Config (Production backend-controlled)
|
||||
* 3. Expo Constants / app.json extra.apiUrl
|
||||
* 4. Auto-detected Host IP (Expo Go)
|
||||
* 5. Platform-specific defaults
|
||||
*/
|
||||
const MANUAL_API_IP = ''; // Set this to '192.168.x.x' to override
|
||||
|
||||
const getBaseUrl = async () => {
|
||||
const version = 'v1';
|
||||
const apiPath = `/api/${version}`;
|
||||
|
||||
// 1. Manual Override (Highest Priority for specific IP needs)
|
||||
if (MANUAL_API_IP) {
|
||||
return `http://${MANUAL_API_IP}:8000${apiPath}`;
|
||||
}
|
||||
|
||||
// 2. Try to get from cached config (Backend-controlled)
|
||||
const cachedConfigStr = await storage.get('cached_mobile_config');
|
||||
if (cachedConfigStr && !__DEV__) {
|
||||
try {
|
||||
const config = JSON.parse(cachedConfigStr);
|
||||
const remoteBase = config?.connectivity?.api_base_url;
|
||||
if (remoteBase && !remoteBase.includes('biiproject.com')) {
|
||||
return `${remoteBase}${apiPath}`;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 3. Try Expo Constants / app.json extra (Developer-controlled)
|
||||
const extraApiUrl = Constants.expoConfig?.extra?.apiUrl;
|
||||
if (extraApiUrl) {
|
||||
const cleanBase = extraApiUrl.endsWith('/') ? extraApiUrl.slice(0, -1) : extraApiUrl;
|
||||
return cleanBase.includes('/api/') ? cleanBase : `${cleanBase}${apiPath}`;
|
||||
}
|
||||
|
||||
// 4. Auto-detect IP from Expo Go (Highest Priority for Local Development)
|
||||
const hostUri = Constants.expoConfig?.hostUri ||
|
||||
(Constants as any).manifest2?.extra?.expoGoConfig?.debuggerHost ||
|
||||
(Constants as any).manifest?.debuggerHost;
|
||||
|
||||
if (hostUri) {
|
||||
const ip = hostUri.split(':')[0];
|
||||
if (ip && ip !== 'localhost' && ip !== '127.0.0.1' && !ip.startsWith('10.0.2.2')) {
|
||||
if (!ip.includes('exp.direct') && !ip.includes('expo.dev')) {
|
||||
return `http://${ip}:8000${apiPath}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Fallback for Web
|
||||
if (Platform.OS === 'web') {
|
||||
if (typeof window !== 'undefined' && window.location) {
|
||||
const hostname = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
|
||||
? 'localhost'
|
||||
: window.location.hostname;
|
||||
return `http://${hostname}:8000${apiPath}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Absolute Fallback (Laravel Sail default tunnel or localhost)
|
||||
return `http://zqfwerzr7b.laravel-sail.site:8080${apiPath}`;
|
||||
};
|
||||
|
||||
const getUrl = async (path: string) => {
|
||||
const baseUrl = await getBaseUrl();
|
||||
const cleanPath = path.startsWith('/') ? path : `/${path}`;
|
||||
const finalUrl = `${baseUrl}${cleanPath}`;
|
||||
if (__DEV__) {
|
||||
console.log(`[API] Req: ${finalUrl}`);
|
||||
}
|
||||
return finalUrl;
|
||||
};
|
||||
|
||||
// Helper for authorized requests
|
||||
const getAuthHeaders = async () => {
|
||||
const token = await storage.get('user_token');
|
||||
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
};
|
||||
};
|
||||
|
||||
// Helper for fetching with timeout and retry
|
||||
const fetchWithTimeout = async (url: string, options: any = {}) => {
|
||||
const cachedConfigStr = await storage.get('cached_mobile_config');
|
||||
let config: any = null;
|
||||
try { config = cachedConfigStr ? JSON.parse(cachedConfigStr) : null; } catch {}
|
||||
|
||||
let timeout = config?.connectivity?.api_timeout_ms || 30000;
|
||||
let retryCount = config?.connectivity?.api_retry_count || 0;
|
||||
|
||||
let lastError: any;
|
||||
|
||||
for (let i = 0; i <= retryCount; i++) {
|
||||
const controller = new AbortController();
|
||||
const id = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(id);
|
||||
|
||||
if (response.status >= 500 && i < retryCount) {
|
||||
throw new Error(`Server Error ${response.status}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error: any) {
|
||||
clearTimeout(id);
|
||||
lastError = error;
|
||||
|
||||
// Only retry on network errors or 5xx server errors
|
||||
const isNetworkError = error.message === 'Network request failed' || error.name === 'AbortError';
|
||||
const isServerError = error.message.includes('Server Error');
|
||||
|
||||
if (i < retryCount && (isNetworkError || isServerError)) {
|
||||
// Exponential backoff: 1s, 2s, 4s...
|
||||
const delay = Math.pow(2, i) * 1000;
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (error.name === 'AbortError') {
|
||||
throw new Error('Request timed out. Please check your connection.');
|
||||
}
|
||||
if (error.message === 'Network request failed') {
|
||||
throw new Error('Cannot connect to server. Please check your internet or VPN.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
};
|
||||
|
||||
export const ApiService = {
|
||||
login: async (email: string, pass: string) => {
|
||||
const response = await fetchWithTimeout(await getUrl('/login'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify({ email, password: pass }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.message || 'Login failed');
|
||||
return data;
|
||||
},
|
||||
|
||||
forgotPassword: async (email: string) => {
|
||||
const response = await fetchWithTimeout(await getUrl('/forgot-password'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.message || 'Request failed');
|
||||
return data;
|
||||
},
|
||||
|
||||
register: async (name: string, email: string, pass: string) => {
|
||||
const response = await fetchWithTimeout(await getUrl('/register'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify({ name, email, password: pass }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.message || 'Registration failed');
|
||||
return data;
|
||||
},
|
||||
|
||||
getUser: async () => {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await fetchWithTimeout(await getUrl('/user'), {
|
||||
method: 'GET',
|
||||
headers,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.message || 'Failed to fetch user');
|
||||
return data;
|
||||
},
|
||||
|
||||
updateAvatar: async (uri: string) => {
|
||||
const headers = await getAuthHeaders();
|
||||
// FormData needs special handling for Content-Type
|
||||
delete (headers as any)['Content-Type'];
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
// Ensure URI is properly formatted for Android
|
||||
const photoUri = Platform.OS === 'android' ? (uri.startsWith('file://') ? uri : `file://${uri}`) : uri;
|
||||
const filename = uri.split('/').pop() || 'avatar.jpg';
|
||||
const match = /\.(\w+)$/.exec(filename);
|
||||
const type = match ? `image/${match[1]}` : `image/jpeg`;
|
||||
|
||||
formData.append('avatar', {
|
||||
uri: photoUri,
|
||||
name: filename,
|
||||
type,
|
||||
} as any);
|
||||
|
||||
const response = await fetchWithTimeout(await getUrl('/profile/avatar'), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.message || 'Avatar update failed');
|
||||
return data;
|
||||
},
|
||||
|
||||
updateProfile: async (name: string, email: string) => {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await fetchWithTimeout(await getUrl('/profile/update'), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ name, email }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.message || 'Update failed');
|
||||
return data;
|
||||
},
|
||||
|
||||
updatePassword: async (current: string, newPass: string, confirmPass: string) => {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await fetchWithTimeout(await getUrl('/profile/password'), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
current_password: current,
|
||||
password: newPass,
|
||||
password_confirmation: confirmPass
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.message || 'Password update failed');
|
||||
return data;
|
||||
},
|
||||
|
||||
deleteAccount: async () => {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await fetchWithTimeout(await getUrl('/profile/delete'), {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.message || 'Account deletion failed');
|
||||
return data;
|
||||
},
|
||||
|
||||
getDashboardData: async () => {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await fetchWithTimeout(await getUrl('/dashboard'), {
|
||||
method: 'GET',
|
||||
headers,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.message || 'Failed to fetch dashboard');
|
||||
return data;
|
||||
},
|
||||
|
||||
getAppConfig: async () => {
|
||||
const response = await fetchWithTimeout(await getUrl('/app-config'), {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' },
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.message || 'Failed to fetch config');
|
||||
return data;
|
||||
},
|
||||
|
||||
syncMobileConfig: async (etag?: string | null) => {
|
||||
const platform = Platform.OS;
|
||||
let version = '2.0.0';
|
||||
try {
|
||||
const cachedConfigStr = await storage.get('cached_mobile_config');
|
||||
if (cachedConfigStr) {
|
||||
const cached = JSON.parse(cachedConfigStr);
|
||||
if (cached?.app_updates?.app_version) version = cached.app_updates.app_version;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const headers: any = { 'Accept': 'application/json' };
|
||||
if (etag) headers['If-None-Match'] = etag;
|
||||
|
||||
const response = await fetchWithTimeout(`${await getUrl('/mobile/sync')}?p=${platform}&v=${version}`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.status === 304) {
|
||||
return { status: 'not_modified' };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.message || 'Failed to sync template');
|
||||
|
||||
return {
|
||||
...data,
|
||||
etag: response.headers.get('ETag')
|
||||
};
|
||||
},
|
||||
|
||||
reportError: async (message: string, level: string = 'error', context: any = {}) => {
|
||||
try {
|
||||
const cachedConfigStr = await storage.get('cached_mobile_config');
|
||||
if (cachedConfigStr) {
|
||||
const config = JSON.parse(cachedConfigStr);
|
||||
// 1. Check if reporting is enabled
|
||||
if (config.analytics_system?.crashlytics_enabled === false) return false;
|
||||
|
||||
// 2. Check Log Level (Priority: critical > error > warning > info > debug)
|
||||
const levels: Record<string, number> = { debug: 0, info: 1, warning: 2, error: 3, critical: 4 };
|
||||
const minLevel = config.analytics_system?.log_level || 'error';
|
||||
if ((levels[level] || 0) < (levels[minLevel] || 3)) return false;
|
||||
}
|
||||
|
||||
const token = await storage.get('user_token');
|
||||
const headers: any = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const url = await getUrl('/mobile/log');
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ level, message, context }),
|
||||
}).catch(() => {});
|
||||
} catch {}
|
||||
return true;
|
||||
},
|
||||
|
||||
registerDeviceToken: async (token: string, platform: string) => {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await fetchWithTimeout(await getUrl('/devices/register'), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ token, platform, type: 'expo' }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.message || 'Token registration failed');
|
||||
return data;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user