194 lines
6.6 KiB
TypeScript
194 lines
6.6 KiB
TypeScript
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,
|
|
}
|
|
});
|