feat: add expo mobile application source code
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import * as LocalAuthentication from 'expo-local-authentication';
|
||||
import { storage } from '../utils/storage';
|
||||
import { Platform } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { ApiService } from '../services/api';
|
||||
import { DebugLogger } from '../utils/logger';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
signIn: (email: string, pass: string) => Promise<void>;
|
||||
signOut: () => void;
|
||||
signUp: (name: string, email: string, pass: string) => Promise<void>;
|
||||
updateProfile: (name: string, email: string) => Promise<void>;
|
||||
syncUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
// Storage helper is now imported from utils/storage
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadStoredToken();
|
||||
}, []);
|
||||
|
||||
const loadStoredToken = async () => {
|
||||
try {
|
||||
const token = await storage.get('user_token');
|
||||
const storedUser = await storage.get('user_data');
|
||||
if (token && storedUser) {
|
||||
setUser(JSON.parse(storedUser));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Authentication failed during boot:', e);
|
||||
await storage.remove('user_token');
|
||||
await storage.remove('user_data');
|
||||
setUser(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const signIn = async (email: string, pass: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await ApiService.login(email, pass);
|
||||
const userData = response.data.user;
|
||||
const token = response.data.token;
|
||||
|
||||
setUser(userData);
|
||||
await storage.save('user_token', token);
|
||||
await storage.save('user_data', JSON.stringify(userData));
|
||||
await storage.save('saved_email', email);
|
||||
await storage.save('saved_pass', pass);
|
||||
DebugLogger.log(`User signed in: ${email}`, 'info');
|
||||
if (Platform.OS !== 'web') Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
} catch (error: any) {
|
||||
DebugLogger.log(`Login failed for ${email}: ${error.message}`, 'error');
|
||||
if (Platform.OS !== 'web') Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const signUp = async (name: string, email: string, pass: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await ApiService.register(name, email, pass);
|
||||
const userData = response.data.user;
|
||||
const token = response.data.token;
|
||||
|
||||
setUser(userData);
|
||||
await storage.save('user_token', token);
|
||||
await storage.save('user_data', JSON.stringify(userData));
|
||||
await storage.save('saved_email', email);
|
||||
await storage.save('saved_pass', pass);
|
||||
if (Platform.OS !== 'web') Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
} catch (error) {
|
||||
if (Platform.OS !== 'web') Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateProfile = async (name: string, email: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await ApiService.updateProfile(name, email);
|
||||
const userData = response.data.user;
|
||||
setUser(userData);
|
||||
await storage.save('user_data', JSON.stringify(userData));
|
||||
if (Platform.OS !== 'web') Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
} catch (error) {
|
||||
if (Platform.OS !== 'web') Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
setUser(null);
|
||||
await storage.remove('user_token');
|
||||
await storage.remove('user_data');
|
||||
if (Platform.OS !== 'web') Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
DebugLogger.log('User signed out', 'info');
|
||||
router.replace('/(auth)/login');
|
||||
};
|
||||
|
||||
const syncUser = async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
const response = await ApiService.getUser();
|
||||
const userData = response.data.user;
|
||||
setUser(userData);
|
||||
await storage.save('user_data', JSON.stringify(userData));
|
||||
} catch (error: any) {
|
||||
DebugLogger.log(`Sync failed: ${error.message}`, 'error');
|
||||
// If it's a 401, we might want to sign out, but for now let's be silent
|
||||
// to avoid the "Global refresh failed" loop that annoys the user.
|
||||
if (error.message.includes('Unauthenticated')) {
|
||||
console.warn('Silent sync failure: User is unauthenticated but staying in app.');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, isLoading, signIn, signOut, signUp, updateProfile, syncUser }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
return {
|
||||
user: null,
|
||||
isLoading: false,
|
||||
signIn: async () => {},
|
||||
signOut: () => {},
|
||||
signUp: async () => {},
|
||||
updateProfile: async () => {},
|
||||
syncUser: async () => {}
|
||||
} as any;
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
|
||||
import { Platform, AppState, AppStateStatus } from 'react-native';
|
||||
import { storage } from '../utils/storage';
|
||||
import { ApiService } from '../services/api';
|
||||
import NetInfo from '@react-native-community/netinfo';
|
||||
import { DebugLogger } from '../utils/logger';
|
||||
|
||||
interface AppConfig {
|
||||
branding: {
|
||||
app_name?: string;
|
||||
app_tagline?: string;
|
||||
app_icon_url?: string;
|
||||
logo_url?: string;
|
||||
splash_image_url?: string;
|
||||
brand_color?: string;
|
||||
theme_color_primary?: string;
|
||||
theme_color_secondary?: string;
|
||||
primary_font_family?: string;
|
||||
};
|
||||
control_center: {
|
||||
kill_switch_active?: boolean;
|
||||
kill_switch_message?: string;
|
||||
maintenance_start_at?: string;
|
||||
maintenance_end_at?: string;
|
||||
maintenance_bypass_ips?: string;
|
||||
announcement_enabled?: boolean;
|
||||
announcement_text?: string;
|
||||
announcement_type?: 'info' | 'warning' | 'danger';
|
||||
};
|
||||
app_updates: {
|
||||
app_version?: string;
|
||||
min_app_version?: string;
|
||||
onboarding_version?: string;
|
||||
store_url_android?: string;
|
||||
store_url_ios?: string;
|
||||
store_url_huawei?: string;
|
||||
};
|
||||
features: {
|
||||
enable_registration?: boolean;
|
||||
enable_guest_mode?: boolean;
|
||||
require_otp_registration?: boolean;
|
||||
enable_biometrics?: boolean;
|
||||
enable_remember_me?: boolean;
|
||||
review_prompt_enabled?: boolean;
|
||||
min_actions_before_review?: number;
|
||||
region_lock_enabled?: boolean;
|
||||
dashboard_categories?: string;
|
||||
};
|
||||
security_auth: {
|
||||
login_title?: string;
|
||||
login_subtitle?: string;
|
||||
token_ttl_minutes?: number;
|
||||
session_max_age?: number;
|
||||
login_max_attempts?: number;
|
||||
biometric_auth_type?: string;
|
||||
oauth_google_enabled?: boolean;
|
||||
oauth_apple_enabled?: boolean;
|
||||
oauth_facebook_enabled?: boolean;
|
||||
};
|
||||
connectivity: {
|
||||
api_base_url?: string;
|
||||
api_version?: string;
|
||||
api_timeout_ms?: number;
|
||||
api_retry_count?: number;
|
||||
request_cache_ttl?: number;
|
||||
sync_interval_ms?: number;
|
||||
enable_ssl_pinning?: boolean;
|
||||
ssl_pinning_hash?: string;
|
||||
environment_selector?: string;
|
||||
};
|
||||
notifications: {
|
||||
enable_push_notifications?: boolean;
|
||||
fcm_topic_default?: string;
|
||||
default_channel_id?: string;
|
||||
notification_sound_enabled?: boolean;
|
||||
badge_count_enabled?: boolean;
|
||||
priority_level?: string;
|
||||
};
|
||||
support_social: {
|
||||
support_email?: string;
|
||||
support_whatsapp?: string;
|
||||
live_chat_url?: string;
|
||||
faq_url?: string;
|
||||
privacy_policy_url?: string;
|
||||
social_instagram_url?: string;
|
||||
social_twitter_url?: string;
|
||||
social_facebook_url?: string;
|
||||
social_youtube_url?: string;
|
||||
faq_json?: any[];
|
||||
help_topics_json?: any[];
|
||||
};
|
||||
analytics_system: {
|
||||
crashlytics_enabled?: boolean;
|
||||
log_level?: string;
|
||||
event_sampling_rate?: string;
|
||||
google_analytics_id?: string;
|
||||
gdpr_compliance_enabled?: boolean;
|
||||
target_sdk_version?: string;
|
||||
system_timezone?: string;
|
||||
default_locale?: string;
|
||||
};
|
||||
localization?: {
|
||||
[lang: string]: Record<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
interface ConfigContextType {
|
||||
config: AppConfig | null;
|
||||
isLoading: boolean;
|
||||
isSyncing: boolean;
|
||||
isConnected: boolean;
|
||||
syncConfig: () => Promise<void>;
|
||||
}
|
||||
|
||||
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
|
||||
|
||||
const STORAGE_KEY = 'cached_mobile_config';
|
||||
const ETAG_KEY = 'cached_config_etag';
|
||||
|
||||
// Minimum sync interval: 3 seconds to feel "instant" while avoiding excessive load
|
||||
const MIN_SYNC_INTERVAL_MS = 3_000;
|
||||
const DEFAULT_SYNC_INTERVAL_MS = 5_000;
|
||||
|
||||
export function ConfigProvider({ children }: { children: React.ReactNode }) {
|
||||
const [config, setConfig] = useState<AppConfig | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isConnected, setIsConnected] = useState(true);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const isSyncingRef = useRef(false);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const prevConnected = useRef<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 1. Monitor network connection
|
||||
const unsubscribeNet = NetInfo.addEventListener(state => {
|
||||
const connected = !!state.isConnected;
|
||||
|
||||
if (prevConnected.current !== null && prevConnected.current !== connected) {
|
||||
DebugLogger.log(`Network status changed: ${connected ? 'Online' : 'Offline'}`, connected ? 'success' : 'error');
|
||||
if (connected) syncConfig();
|
||||
}
|
||||
|
||||
setIsConnected(connected);
|
||||
prevConnected.current = connected;
|
||||
});
|
||||
|
||||
// 2. Monitor AppState (Sync on foreground)
|
||||
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
||||
if (nextAppState === 'active') {
|
||||
syncConfig();
|
||||
}
|
||||
});
|
||||
|
||||
loadInitialConfig();
|
||||
|
||||
return () => {
|
||||
unsubscribeNet();
|
||||
subscription.remove();
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Re-setup interval when config changes (for dynamic sync_interval_ms)
|
||||
useEffect(() => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
|
||||
const rawInterval = config?.connectivity?.sync_interval_ms ?? DEFAULT_SYNC_INTERVAL_MS;
|
||||
// Clamp to minimum to avoid hammering server
|
||||
const safeInterval = Math.max(rawInterval, MIN_SYNC_INTERVAL_MS);
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
syncConfig();
|
||||
}, safeInterval);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [config?.connectivity?.sync_interval_ms]);
|
||||
|
||||
const loadInitialConfig = async () => {
|
||||
try {
|
||||
// 1. Load from cache first (offline-first approach)
|
||||
const str = await storage.get(STORAGE_KEY);
|
||||
if (str) {
|
||||
try {
|
||||
const cached = JSON.parse(str);
|
||||
setConfig(cached);
|
||||
setIsLoading(false);
|
||||
} catch {
|
||||
// Cache is corrupted, remove it
|
||||
await storage.remove(STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch fresh config in background (non-blocking)
|
||||
await syncConfig();
|
||||
} catch (error) {
|
||||
console.warn('[Config] Initialization error:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const syncConfig = async () => {
|
||||
if (isSyncingRef.current) return;
|
||||
isSyncingRef.current = true;
|
||||
setIsSyncing(true);
|
||||
|
||||
try {
|
||||
const etag = await storage.get(ETAG_KEY);
|
||||
const response = await ApiService.syncMobileConfig(etag);
|
||||
|
||||
if (response?.status === 'not_modified') {
|
||||
DebugLogger.log('Config is up to date (ETag match)', 'sync');
|
||||
return;
|
||||
}
|
||||
|
||||
if (response?.status === 'success' && response?.data) {
|
||||
const freshConfig = response.data;
|
||||
setConfig(freshConfig);
|
||||
|
||||
DebugLogger.log(`Config updated with ETag: ${response.etag?.substring(0, 8)}...`, 'sync');
|
||||
// Persist to cache
|
||||
await storage.save(STORAGE_KEY, JSON.stringify(freshConfig));
|
||||
if (response.etag) {
|
||||
await storage.save(ETAG_KEY, response.etag);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
DebugLogger.log(`Sync error: ${error.message}`, 'error');
|
||||
console.warn('[Config] Sync failed (offline?):', error);
|
||||
} finally {
|
||||
isSyncingRef.current = false;
|
||||
setIsSyncing(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigContext.Provider value={{ config, isLoading, isSyncing, isConnected, syncConfig }}>
|
||||
{children}
|
||||
</ConfigContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAppConfig() {
|
||||
const context = useContext(ConfigContext);
|
||||
if (context === undefined) {
|
||||
return {
|
||||
config: {
|
||||
branding: { app_name: 'biiproject' },
|
||||
control_center: {},
|
||||
app_updates: {},
|
||||
features: {},
|
||||
security_auth: {},
|
||||
connectivity: {},
|
||||
notifications: {},
|
||||
support_social: {},
|
||||
analytics_system: {}
|
||||
},
|
||||
isLoading: false,
|
||||
isSyncing: false,
|
||||
isConnected: true,
|
||||
syncConfig: async () => {}
|
||||
} as any;
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import { storage } from '../utils/storage';
|
||||
import { useAppConfig } from './ConfigContext';
|
||||
|
||||
export type LanguageType = 'English' | 'Indonesian';
|
||||
|
||||
interface Translations {
|
||||
[key: string]: {
|
||||
[key: string]: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const translations: Translations = {
|
||||
English: {
|
||||
// Auth
|
||||
login: 'Login Now',
|
||||
register: 'Register Now',
|
||||
createAccount: 'Create New Account',
|
||||
registerSubtitle: 'Join us to start managing your workspace efficiently.',
|
||||
email: 'Email Address',
|
||||
password: 'Password',
|
||||
fullName: 'Full Name',
|
||||
forgotPass: 'Forgot Password?',
|
||||
rememberMe: 'Remember Me',
|
||||
noAccount: "Don't have an account? ",
|
||||
haveAccount: 'Already have an account? ',
|
||||
loginJust: 'Login Now',
|
||||
termsText: 'By registering, you agree to our ',
|
||||
termsLink: 'Terms of Service',
|
||||
privacyLink: 'Privacy Policy',
|
||||
and: ' and ',
|
||||
|
||||
// Auth Extra
|
||||
signIn: 'Sign In',
|
||||
emailPlaceholder: 'email@example.com',
|
||||
passwordPlaceholder: '••••••••',
|
||||
signInNow: 'Sign In Now',
|
||||
orContinueWith: 'OR CONTINUE WITH',
|
||||
signUp: 'Sign Up',
|
||||
google: 'Google',
|
||||
apple: 'Apple',
|
||||
welcomeBack: 'Welcome back!',
|
||||
invalidEmail: 'Invalid email address',
|
||||
loginFailed: 'Login failed. Please try again.',
|
||||
bioConfirm: 'Confirm Identity',
|
||||
bioFailed: 'Biometric authentication failed',
|
||||
bioSuccess: 'Biometric Login Successful!',
|
||||
|
||||
// Auth Extra 2
|
||||
fillAll: 'Please fill all required fields',
|
||||
passMismatch: 'Passwords do not match',
|
||||
accountCreated: 'Account created successfully!',
|
||||
confirmPassword: 'Confirm Password',
|
||||
namePlaceholder: 'John Doe',
|
||||
registering: 'Creating account...',
|
||||
|
||||
// Profile Extra
|
||||
uploadingAvatar: 'Uploading avatar...',
|
||||
avatarUpdated: 'Profile picture updated!',
|
||||
profileUpdated: 'Profile updated successfully!',
|
||||
logoutSafe: 'Signing out safely...',
|
||||
confirmLogout: 'Are you sure you want to log out?',
|
||||
cancel: 'Cancel',
|
||||
logout: 'Logout',
|
||||
accCreated: 'Account created successfully!',
|
||||
regFailed: 'Registration failed',
|
||||
join: 'Join',
|
||||
confirmPass: 'Confirm Password',
|
||||
|
||||
// Dashboard
|
||||
halo: 'Hello',
|
||||
role: 'Your Role: ',
|
||||
lastStatus: 'Last Status: ',
|
||||
history: 'Recent Activity',
|
||||
searchPlaceholder: 'Search items or locations...',
|
||||
loadMore: 'Load More',
|
||||
all: 'All',
|
||||
pending: 'Pending',
|
||||
completed: 'Success',
|
||||
high: 'Urgent',
|
||||
searching: 'Searching server...',
|
||||
|
||||
// Dashboard Extra
|
||||
systemSupport: 'System Support',
|
||||
instantHelp: 'Instant Help 24/7',
|
||||
getHelp: 'Get Help',
|
||||
quickActions: 'Quick Actions',
|
||||
categories: 'Categories',
|
||||
latestDiscoveries: 'Latest Discoveries',
|
||||
account: 'Account',
|
||||
subscription: 'Subscription',
|
||||
system: 'System',
|
||||
explore: 'Explore',
|
||||
|
||||
// Profile
|
||||
personalData: 'PERSONAL DATA',
|
||||
fullNameLabel: 'Full Name',
|
||||
editProfile: 'Edit Profile Information',
|
||||
confirmChanges: 'Confirm Changes',
|
||||
syncing: 'Syncing...',
|
||||
preferences: 'PREFERENCES & SECURITY',
|
||||
darkTheme: 'Dark Mode',
|
||||
changePass: 'Change Password',
|
||||
biometrics: 'Biometrics',
|
||||
language: 'Language',
|
||||
logout: 'Logout Account',
|
||||
updateSecurity: 'Update Security',
|
||||
oldPass: 'Old Password',
|
||||
newPass: 'New Password',
|
||||
confirmNew: 'Confirm New',
|
||||
update: 'Update',
|
||||
cancel: 'Cancel',
|
||||
chooseLang: 'Choose Language',
|
||||
close: 'Close',
|
||||
confirmLogout: 'Confirm Logout',
|
||||
areYouSureLogout: 'Are you sure you want to logout from your account?',
|
||||
|
||||
// Notifications
|
||||
notifications: 'Notifications',
|
||||
markAllRead: 'Mark all as read',
|
||||
noNotifications: 'No new notifications',
|
||||
|
||||
// Help
|
||||
helpCenter: 'Help Center',
|
||||
helpSubtitle: 'We are ready to help you with any questions or technical issues.',
|
||||
emergencyTitle: 'Direct Support',
|
||||
emergencySubtitle: 'Contact us for urgent assistance',
|
||||
contactSupport: 'Contact Us',
|
||||
faqTitle: 'Frequently Asked Questions (FAQ)',
|
||||
|
||||
// Help Extra
|
||||
supportCenter: 'Support Center',
|
||||
searchDoc: 'Search documentation...',
|
||||
browseTopics: 'Browse Topics',
|
||||
popularFaq: 'Popular FAQs',
|
||||
whatsapp: 'WhatsApp',
|
||||
emailSupport: 'Email Support',
|
||||
web: 'Web',
|
||||
billing: 'Billing',
|
||||
// Notifications Extra
|
||||
recentNotifications: 'recent notifications',
|
||||
},
|
||||
Indonesian: {
|
||||
// Auth
|
||||
login: 'Masuk Sekarang',
|
||||
register: 'Daftar Sekarang',
|
||||
createAccount: 'Buat Akun Baru',
|
||||
registerSubtitle: 'Bergabunglah dengan kami untuk mulai mengelola ruang kerja Anda.',
|
||||
email: 'Alamat Email',
|
||||
password: 'Kata Sandi',
|
||||
fullName: 'Nama Lengkap',
|
||||
forgotPass: 'Lupa Kata Sandi?',
|
||||
rememberMe: 'Ingat Saya',
|
||||
noAccount: 'Belum punya akun? ',
|
||||
haveAccount: 'Sudah punya akun? ',
|
||||
loginJust: 'Masuk Saja',
|
||||
termsText: 'Dengan mendaftar, Anda menyetujui ',
|
||||
termsLink: 'Ketentuan Layanan',
|
||||
privacyLink: 'Kebijakan Privasi',
|
||||
and: ' dan ',
|
||||
|
||||
// Auth Extra
|
||||
signIn: 'Masuk',
|
||||
emailPlaceholder: 'email@contoh.com',
|
||||
passwordPlaceholder: '••••••••',
|
||||
signInNow: 'Masuk Sekarang',
|
||||
orContinueWith: 'ATAU LANJUTKAN DENGAN',
|
||||
signUp: 'Daftar',
|
||||
google: 'Google',
|
||||
apple: 'Apple',
|
||||
welcomeBack: 'Selamat datang kembali!',
|
||||
invalidEmail: 'Alamat email tidak valid',
|
||||
loginFailed: 'Login gagal. Silakan coba lagi.',
|
||||
bioConfirm: 'Konfirmasi Identitas',
|
||||
bioFailed: 'Autentikasi biometrik gagal',
|
||||
bioSuccess: 'Login Biometrik Berhasil!',
|
||||
|
||||
// Auth Extra 2
|
||||
fillAll: 'Silakan isi semua bidang yang diperlukan',
|
||||
passMismatch: 'Kata sandi tidak cocok',
|
||||
accountCreated: 'Akun berhasil dibuat!',
|
||||
confirmPassword: 'Konfirmasi Kata Sandi',
|
||||
namePlaceholder: 'John Doe',
|
||||
registering: 'Membuat akun...',
|
||||
|
||||
// Profile Extra
|
||||
uploadingAvatar: 'Mengunggah foto...',
|
||||
avatarUpdated: 'Foto profil diperbarui!',
|
||||
profileUpdated: 'Profil berhasil diperbarui!',
|
||||
logoutSafe: 'Keluar dengan aman...',
|
||||
confirmLogout: 'Apakah Anda yakin ingin keluar?',
|
||||
cancel: 'Batal',
|
||||
logout: 'Keluar',
|
||||
accCreated: 'Akun berhasil dibuat!',
|
||||
regFailed: 'Pendaftaran gagal',
|
||||
join: 'Bergabunglah dengan',
|
||||
confirmPass: 'Konfirmasi Kata Sandi',
|
||||
namePlaceholder: 'Budi Santoso',
|
||||
|
||||
// Dashboard
|
||||
halo: 'Halo',
|
||||
role: 'Peran Anda: ',
|
||||
lastStatus: 'Status Terakhir: ',
|
||||
history: 'Aktivitas Terbaru',
|
||||
searchPlaceholder: 'Cari item atau lokasi...',
|
||||
updateNow: 'Perbarui Sekarang',
|
||||
all: 'Semua',
|
||||
pending: 'Tertunda',
|
||||
completed: 'Selesai',
|
||||
high: 'Penting',
|
||||
searching: 'Mencari di server...',
|
||||
|
||||
// Dashboard Extra
|
||||
systemSupport: 'Dukungan Sistem',
|
||||
instantHelp: 'Bantuan Instan 24/7',
|
||||
getHelp: 'Dapatkan Bantuan',
|
||||
quickActions: 'Aksi Cepat',
|
||||
categories: 'Kategori',
|
||||
latestDiscoveries: 'Penemuan Terbaru',
|
||||
account: 'Akun',
|
||||
subscription: 'Langganan',
|
||||
system: 'Sistem',
|
||||
explore: 'Jelajahi',
|
||||
|
||||
// Profile
|
||||
personalData: 'DATA PRIBADI',
|
||||
fullNameLabel: 'Nama Lengkap',
|
||||
editProfile: 'Ubah Informasi Profil',
|
||||
confirmChanges: 'Simpan Perubahan',
|
||||
syncing: 'Menyinkronkan...',
|
||||
preferences: 'PREFERENSI & KEAMANAN',
|
||||
darkTheme: 'Mode Gelap',
|
||||
changePass: 'Ubah Kata Sandi',
|
||||
biometrics: 'Biometrik',
|
||||
language: 'Bahasa',
|
||||
logout: 'Keluar Akun',
|
||||
updateSecurity: 'Perbarui Keamanan',
|
||||
oldPass: 'Sandi Lama',
|
||||
newPass: 'Sandi Baru',
|
||||
confirmNew: 'Konfirmasi Sandi Baru',
|
||||
update: 'Perbarui',
|
||||
cancel: 'Batal',
|
||||
chooseLang: 'Pilih Bahasa',
|
||||
close: 'Tutup',
|
||||
confirmLogout: 'Konfirmasi Keluar',
|
||||
areYouSureLogout: 'Apakah Anda yakin ingin keluar dari akun Anda?',
|
||||
|
||||
// Notifications
|
||||
notifications: 'Pemberitahuan',
|
||||
markAllRead: 'Tandai semua dibaca',
|
||||
noNotifications: 'Tidak ada pemberitahuan baru',
|
||||
|
||||
// Notifications Extra
|
||||
recentNotifications: 'pemberitahuan terbaru',
|
||||
|
||||
// Help
|
||||
helpCenter: 'Pusat Bantuan',
|
||||
helpSubtitle: 'Kami siap membantu Anda dengan pertanyaan atau kendala teknis.',
|
||||
emergencyTitle: 'Dukungan Langsung',
|
||||
emergencySubtitle: 'Hubungi kami untuk bantuan mendesak',
|
||||
contactSupport: 'Hubungi Kami',
|
||||
faqTitle: 'Pertanyaan Umum (FAQ)',
|
||||
|
||||
// Help Extra
|
||||
supportCenter: 'Pusat Dukungan',
|
||||
searchDoc: 'Cari dokumentasi...',
|
||||
browseTopics: 'Telusuri Topik',
|
||||
popularFaq: 'FAQ Populer',
|
||||
whatsapp: 'WhatsApp',
|
||||
emailSupport: 'Dukungan Email',
|
||||
web: 'Web',
|
||||
billing: 'Tagihan',
|
||||
}
|
||||
};
|
||||
|
||||
interface LanguageContextType {
|
||||
language: LanguageType;
|
||||
setLanguage: (lang: LanguageType) => void;
|
||||
t: any;
|
||||
}
|
||||
|
||||
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
||||
|
||||
export function LanguageProvider({ children }: { children: React.ReactNode }) {
|
||||
const [language, setRawLanguage] = useState<LanguageType>('English');
|
||||
const { config } = useAppConfig();
|
||||
|
||||
useEffect(() => {
|
||||
loadLanguage();
|
||||
}, []);
|
||||
|
||||
const loadLanguage = async () => {
|
||||
const saved = await storage.get('pref_language');
|
||||
if (saved === 'English' || saved === 'Indonesian') {
|
||||
setRawLanguage(saved);
|
||||
}
|
||||
};
|
||||
|
||||
const setLanguage = async (newLang: LanguageType) => {
|
||||
setRawLanguage(newLang);
|
||||
await storage.save('pref_language', newLang);
|
||||
};
|
||||
|
||||
// Merge static translations with dynamic ones from Laravel
|
||||
const t = {
|
||||
...translations[language],
|
||||
...(config?.localization?.[language] || {})
|
||||
};
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider value={{ language, setLanguage, t }}>
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTranslation() {
|
||||
const context = useContext(LanguageContext);
|
||||
if (context === undefined) {
|
||||
return {
|
||||
language: 'English',
|
||||
setLanguage: () => {},
|
||||
t: translations.English
|
||||
};
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import React, { createContext, useContext, useState } from 'react';
|
||||
import { useAuth } from './AuthContext';
|
||||
import { ApiService } from '../services/api';
|
||||
|
||||
interface RefreshContextType {
|
||||
refreshing: boolean;
|
||||
refreshAll: () => Promise<void>;
|
||||
}
|
||||
|
||||
const RefreshContext = createContext<RefreshContextType | undefined>(undefined);
|
||||
|
||||
export function RefreshProvider({ children }: { children: React.ReactNode }) {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const { syncUser } = useAuth();
|
||||
|
||||
const refreshAll = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
// Sync User Data
|
||||
await syncUser();
|
||||
// You can add more global syncs here (e.g., config, notifications)
|
||||
} catch (e) {
|
||||
console.error('Global refresh failed', e);
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<RefreshContext.Provider value={{ refreshing, refreshAll }}>
|
||||
{children}
|
||||
</RefreshContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useRefresh() {
|
||||
const context = useContext(RefreshContext);
|
||||
if (context === undefined) throw new Error('useRefresh must be used within a RefreshProvider');
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { useColorScheme, Platform } from 'react-native';
|
||||
import { Theme } from '../constants/theme';
|
||||
import { storage } from '../utils/storage';
|
||||
import { useAppConfig } from './ConfigContext';
|
||||
|
||||
type ThemeMode = 'light' | 'dark' | 'system';
|
||||
|
||||
interface ThemeContextType {
|
||||
mode: ThemeMode;
|
||||
setMode: (mode: ThemeMode) => void;
|
||||
colors: typeof Theme.dark;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
// Storage helper is now imported from utils/storage
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const systemColorScheme = useColorScheme();
|
||||
const [mode, setRawMode] = useState<ThemeMode>('light');
|
||||
const { config } = useAppConfig();
|
||||
|
||||
useEffect(() => {
|
||||
loadTheme();
|
||||
}, []);
|
||||
|
||||
const loadTheme = async () => {
|
||||
const savedMode = await storage.get('theme_mode');
|
||||
if (savedMode) {
|
||||
setRawMode(savedMode as ThemeMode);
|
||||
}
|
||||
};
|
||||
|
||||
const setMode = async (newMode: ThemeMode) => {
|
||||
setRawMode(newMode);
|
||||
await storage.save('theme_mode', newMode);
|
||||
};
|
||||
|
||||
const isDark = mode === 'system' ? systemColorScheme === 'dark' : mode === 'dark';
|
||||
|
||||
// Layer admin-controlled brand colors and logo on top of the static design tokens
|
||||
// so consumers always receive the full palette (surfaceLight, glass, accent, etc).
|
||||
const baseColors = isDark ? Theme.dark : Theme.light;
|
||||
const colors = {
|
||||
...baseColors,
|
||||
primary: config?.branding?.theme_color_primary || baseColors.primary,
|
||||
accent: config?.branding?.theme_color_primary || baseColors.accent,
|
||||
secondary: config?.branding?.theme_color_secondary || baseColors.secondary,
|
||||
logo: config?.branding?.logo_url || null,
|
||||
} as typeof Theme.dark & { logo: string | null };
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ mode, setMode, colors, isDark }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAppTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
// Fallback safely instead of crashing — return the full design-token palette
|
||||
return {
|
||||
mode: 'light',
|
||||
setMode: () => {},
|
||||
isDark: false,
|
||||
colors: { ...Theme.light, logo: null }
|
||||
};
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useRef } from 'react';
|
||||
import { View, Text, StyleSheet, Animated, Platform, Dimensions } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { useAppTheme } from './ThemeContext';
|
||||
|
||||
interface ToastContextType {
|
||||
showToast: (message: string, type?: 'success' | 'error' | 'info') => void;
|
||||
}
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const LIME = '#C6F135';
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const { colors, isDark } = useAppTheme();
|
||||
const [toast, setToast] = useState<{ message: string, type: string } | null>(null);
|
||||
|
||||
const translateY = useRef(new Animated.Value(-120)).current;
|
||||
const opacity = useRef(new Animated.Value(0)).current;
|
||||
|
||||
const showToast = useCallback((message: string, type: 'success' | 'error' | 'info' = 'info') => {
|
||||
setToast({ message, type });
|
||||
|
||||
translateY.setValue(-120);
|
||||
opacity.setValue(0);
|
||||
|
||||
Animated.sequence([
|
||||
Animated.parallel([
|
||||
Animated.spring(translateY, { toValue: Platform.OS === 'ios' ? 60 : 40, useNativeDriver: true, friction: 9, tension: 50 }),
|
||||
Animated.timing(opacity, { toValue: 1, duration: 400, useNativeDriver: true })
|
||||
]),
|
||||
Animated.delay(2800),
|
||||
Animated.parallel([
|
||||
Animated.timing(translateY, { toValue: -120, duration: 300, useNativeDriver: true }),
|
||||
Animated.timing(opacity, { toValue: 0, duration: 300, useNativeDriver: true })
|
||||
])
|
||||
]).start(() => setToast(null));
|
||||
}, []);
|
||||
|
||||
// Theme configuration for the toast
|
||||
const getMeta = () => {
|
||||
if (!toast) return { icon: 'information', color: LIME };
|
||||
switch(toast.type) {
|
||||
case 'success': return { icon: 'check-circle', color: LIME };
|
||||
case 'error': return { icon: 'alert-circle', color: '#EF4444' };
|
||||
default: return { icon: 'information', color: '#3B82F6' };
|
||||
}
|
||||
};
|
||||
|
||||
const meta = getMeta();
|
||||
const toastBg = isDark ? '#1A1A1A' : '#1A1A1A'; // Solid dark toast for both modes is more premium
|
||||
const border = isDark ? '#2A2A2A' : '#333';
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
{toast && (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
opacity,
|
||||
transform: [{ translateY }],
|
||||
zIndex: 10000,
|
||||
}
|
||||
]}
|
||||
pointerEvents="none"
|
||||
>
|
||||
<View style={[
|
||||
styles.toast,
|
||||
{
|
||||
backgroundColor: toastBg,
|
||||
borderColor: border,
|
||||
}
|
||||
]}>
|
||||
<View style={[styles.iconBox, { backgroundColor: `${meta.color}20` }]}>
|
||||
<MaterialCommunityIcons
|
||||
name={meta.icon as any}
|
||||
size={22}
|
||||
color={meta.color}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.text}>{toast.message}</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
if (context === undefined) {
|
||||
return {
|
||||
showToast: (msg: string) => console.log('Toast (Fallback):', msg)
|
||||
};
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 20,
|
||||
right: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
toast: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 15 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 20,
|
||||
elevation: 12,
|
||||
width: '100%',
|
||||
maxWidth: 450,
|
||||
},
|
||||
iconBox: {
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
text: {
|
||||
marginLeft: 14,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Outfit_700Bold',
|
||||
color: '#FFFFFF',
|
||||
flexShrink: 1,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user