feat: add expo mobile application source code
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import { ThemeProvider, useAppTheme } from '../context/ThemeContext';
|
||||
import { AuthProvider, useAuth } from '../context/AuthContext';
|
||||
import { ToastProvider } from '../context/ToastContext';
|
||||
import { RefreshProvider, useRefresh } from '../context/RefreshContext';
|
||||
import { ConfigProvider, useAppConfig } from '../context/ConfigContext';
|
||||
import { LanguageProvider } from '../context/LanguageContext';
|
||||
import { View, Platform, StyleSheet, TouchableOpacity, Text, ActivityIndicator, StatusBar } from 'react-native';
|
||||
import { Feather } from '@expo/vector-icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import * as SplashScreen from 'expo-splash-screen';
|
||||
import { useFonts, Outfit_300Light, Outfit_400Regular, Outfit_500Medium, Outfit_600SemiBold, Outfit_700Bold, Outfit_800ExtraBold } from '@expo-google-fonts/outfit';
|
||||
import { AnimatedSplash } from '../components/AnimatedSplash';
|
||||
import { ErrorBoundary } from '../components/ErrorBoundary';
|
||||
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
|
||||
export default function RootLayout() {
|
||||
const [fontsLoaded, fontError] = useFonts({
|
||||
Outfit_300Light,
|
||||
Outfit_400Regular,
|
||||
Outfit_500Medium,
|
||||
Outfit_600SemiBold,
|
||||
Outfit_700Bold,
|
||||
Outfit_800ExtraBold,
|
||||
});
|
||||
|
||||
const [isAnimationComplete, setIsAnimationComplete] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (fontsLoaded || fontError) {
|
||||
SplashScreen.hideAsync();
|
||||
}
|
||||
}, [fontsLoaded, fontError]);
|
||||
|
||||
if (!fontsLoaded && !fontError) return <View style={{ flex: 1, backgroundColor: '#020617' }} />;
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<ConfigProvider>
|
||||
<ThemeProvider>
|
||||
<LanguageProvider>
|
||||
<AuthProvider>
|
||||
<RefreshProvider>
|
||||
<ToastProvider>
|
||||
{!isAnimationComplete ? (
|
||||
<AnimatedSplash key="splash-screen" onAnimationComplete={() => setIsAnimationComplete(true)} />
|
||||
) : (
|
||||
<RootLayoutContent key="main-app-content" />
|
||||
)}
|
||||
</ToastProvider>
|
||||
</RefreshProvider>
|
||||
</AuthProvider>
|
||||
</LanguageProvider>
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
import { KillSwitchOverlay } from '../components/KillSwitchOverlay';
|
||||
import { AnnouncementBanner } from '../components/AnnouncementBanner';
|
||||
|
||||
function RootLayoutContent() {
|
||||
// Safe hook call because it's now guaranteed to be rendered ONLY when providers are ready and animation is done
|
||||
const { colors } = useAppTheme();
|
||||
const { config, isConnected, isSyncing, syncConfig } = useAppConfig();
|
||||
const [announcementDismissed, setAnnouncementDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected) syncConfig();
|
||||
}, [isConnected]);
|
||||
|
||||
// Logic for Kill Switch & Maintenance
|
||||
const isKillSwitchActive = config?.control_center?.kill_switch_active || false;
|
||||
|
||||
// Calculate if maintenance is currently active
|
||||
const isInMaintenanceWindow = () => {
|
||||
const start = config?.control_center?.maintenance_start_at;
|
||||
const end = config?.control_center?.maintenance_end_at;
|
||||
if (!start || !end) return false;
|
||||
|
||||
const now = new Date().getTime();
|
||||
const startTime = new Date(start).getTime();
|
||||
const endTime = new Date(end).getTime();
|
||||
|
||||
return now >= startTime && now <= endTime;
|
||||
};
|
||||
|
||||
const shouldBlockAccess = isKillSwitchActive || isInMaintenanceWindow();
|
||||
|
||||
// Logic for Force Update — current version comes from synced config so admin can advertise the released build
|
||||
const currentAppVersion = config?.app_updates?.app_version || "2.0.0";
|
||||
const minVersion = config?.app_updates?.min_app_version || "1.0.0";
|
||||
|
||||
const isUpdateRequired = () => {
|
||||
if (!minVersion) return false;
|
||||
const current = currentAppVersion.split('.').map(Number);
|
||||
const min = minVersion.split('.').map(Number);
|
||||
|
||||
for (let i = 0; i < Math.max(current.length, min.length); i++) {
|
||||
const v1 = current[i] || 0;
|
||||
const v2 = min[i] || 0;
|
||||
if (v1 < v2) return true;
|
||||
if (v1 > v2) return false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const isUpdating = isUpdateRequired();
|
||||
|
||||
const killSwitchMessage = isKillSwitchActive
|
||||
? config?.control_center?.kill_switch_message
|
||||
: (isInMaintenanceWindow() ? "System is currently undergoing scheduled maintenance." : "");
|
||||
|
||||
const updateMessage = `A mandatory update (v${minVersion}) is required to continue using the app. Please update from the store.`;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<StatusBar barStyle={Platform.OS === 'ios' ? 'dark-content' : 'default'} />
|
||||
|
||||
{/* 1. Global Announcement */}
|
||||
<AnnouncementBanner
|
||||
visible={!!config?.control_center?.announcement_enabled && !announcementDismissed && !shouldBlockAccess}
|
||||
message={config?.control_center?.announcement_text || ''}
|
||||
type={config?.control_center?.announcement_type}
|
||||
onClose={() => setAnnouncementDismissed(true)}
|
||||
/>
|
||||
|
||||
{/* 2. Kill Switch / Maintenance Overlay */}
|
||||
<KillSwitchOverlay
|
||||
visible={shouldBlockAccess}
|
||||
message={killSwitchMessage}
|
||||
supportEmail={config?.support_social?.support_email}
|
||||
/>
|
||||
|
||||
{/* 3. Force Update Overlay */}
|
||||
<KillSwitchOverlay
|
||||
visible={isUpdating && !shouldBlockAccess}
|
||||
message={updateMessage}
|
||||
/>
|
||||
|
||||
{/* Offline Indicator */}
|
||||
{!isConnected && (
|
||||
<View style={[styles.offlineBanner, { backgroundColor: colors.error }]}>
|
||||
<Feather name="wifi-off" size={14} color="#FFF" />
|
||||
<Text style={styles.offlineText}>You are currently offline.</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Sync Indicator */}
|
||||
{isSyncing && isConnected && (
|
||||
<View style={styles.syncIndicator}>
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Stack screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: colors.background },
|
||||
animation: 'fade'
|
||||
}}>
|
||||
<Stack.Screen name="(auth)" options={{ animation: 'fade' }} />
|
||||
<Stack.Screen name="(tabs)" options={{ animation: 'fade' }} />
|
||||
</Stack>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
offlineBanner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 8,
|
||||
gap: 8,
|
||||
zIndex: 999,
|
||||
},
|
||||
offlineText: {
|
||||
color: '#FFF',
|
||||
fontSize: 12,
|
||||
fontFamily: 'Outfit_600SemiBold',
|
||||
},
|
||||
syncIndicator: {
|
||||
position: 'absolute',
|
||||
top: Platform.OS === 'ios' ? 60 : 40,
|
||||
right: 20,
|
||||
zIndex: 1000,
|
||||
backgroundColor: 'rgba(0,0,0,0.05)',
|
||||
borderRadius: 20,
|
||||
padding: 4,
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user