feat: add expo mobile application source code

This commit is contained in:
2026-05-21 16:06:35 +07:00
parent 76d7a5c5c6
commit 0c65a7811b
77 changed files with 20356 additions and 0 deletions
+165
View File
@@ -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;
}
+268
View File
@@ -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;
}
+328
View File
@@ -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;
}
+40
View File
@@ -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;
}
+73
View File
@@ -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;
}
+139
View File
@@ -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,
},
});