Files

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,
}
});