Yaltopia-Tickets-App/app/_layout.tsx
2026-05-14 22:29:28 +03:00

270 lines
9.1 KiB
TypeScript

import React, { useEffect, useState } from "react";
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { PortalHost } from "@rn-primitives/portal";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { Toast } from "@/components/Toast";
import "@/global.css";
import { SafeAreaProvider } from "react-native-safe-area-context";
import {
View,
ActivityIndicator,
InteractionManager,
AppState,
} from "react-native";
import { useRestoreTheme, NAV_THEME } from "@/lib/theme";
import { SirouRouterProvider, useSirouRouter } from "@sirou/react-native";
import { refreshTokens } from "@/lib/api-middlewares";
import {
NavigationContainer,
NavigationIndependentTree,
ThemeProvider,
} from "@react-navigation/native";
import { routes } from "@/lib/routes";
import { authGuard, guestGuard } from "@/lib/auth-guards";
import { useAuthStore } from "@/lib/auth-store";
import { useFonts } from "expo-font";
import { api } from "@/lib/api";
import { useColorScheme } from "nativewind";
import { useSegments, useLocalSearchParams, router } from "expo-router";
/**
* GlobalGuard: Handles all routing security and authentication redirects.
* Reacts instantly to auth state changes to prevent unauthenticated users from seeing protected data.
*/
function GlobalGuard() {
const segments = useSegments();
const params = useLocalSearchParams();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const sirou = useSirouRouter();
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
useEffect(() => {
if (!isMounted) return;
const performGuardCheck = async () => {
const routeName = segments.length > 0 ? segments.join("/") : "root";
const isAuthPage =
segments[0] === "login" ||
segments[0] === "register" ||
segments[0] === "otp";
// 1. FAST AUTH CHECK: If not authenticated and not on a public page, eject immediately.
if (!isAuthenticated && !isAuthPage) {
console.log(`[GlobalGuard] Unauthorized on "${routeName}". Ejecting...`);
router.replace("/login");
return;
}
// 2. GUEST CHECK: If authenticated and on an auth page, redirect to home.
if (isAuthenticated && isAuthPage) {
console.log(`[GlobalGuard] Authenticated user on auth page. Sending home.`);
router.replace("/");
return;
}
// 3. COMPLEX GUARDS: Permissions, roles, etc. handled by Sirou.
try {
const result = await (sirou as any).checkGuards(routeName, params);
if (!result.allowed && result.redirect) {
console.log(`[GlobalGuard] Sirou Guard Redirect -> ${result.redirect}`);
router.replace(result.redirect as any);
}
} catch (e: any) {
console.warn(`[GlobalGuard] guard crash:`, e.message);
}
};
performGuardCheck();
}, [segments, params, sirou, isMounted, isAuthenticated]);
return null;
}
/**
* SessionHeartbeat: Proactively refreshes tokens every 5 minutes and upon app foregrounding.
*/
function SessionHeartbeat() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
useEffect(() => {
if (!isAuthenticated) return;
// Refresh every 5 minutes
const INTERVAL_MS = 5 * 60 * 1000;
const performRefresh = async (reason: string) => {
try {
console.log(`[SessionHeartbeat] Refresh triggered by: ${reason}`);
await refreshTokens();
} catch (err) {
console.warn(`[SessionHeartbeat] Refresh failed (${reason}):`, err);
}
};
// 1. Initial/Interval Refresh
performRefresh("Mount"); // Refresh immediately on mount
const interval = setInterval(() => performRefresh("Interval"), INTERVAL_MS);
// 2. Foreground Refresh (AppState listener)
const subscription = AppState.addEventListener("change", (nextAppState) => {
if (nextAppState === "active") {
performRefresh("Foreground");
}
});
return () => {
clearInterval(interval);
subscription.remove();
};
}, [isAuthenticated]);
return null;
}
export default function RootLayout() {
const { colorScheme } = useColorScheme();
useRestoreTheme();
const [isMounted, setIsMounted] = useState(false);
const [hasHydrated, setHasHydrated] = useState(false);
const [fontsLoaded] = useFonts({
"DMSans-Regular": require("../assets/fonts/DMSans-Regular.ttf"),
"DMSans-Bold": require("../assets/fonts/DMSans-Bold.ttf"),
"DMSans-Medium": require("../assets/fonts/DMSans-Medium.ttf"),
"DMSans-SemiBold": require("../assets/fonts/DMSans-SemiBold.ttf"),
"DMSans-Light": require("../assets/fonts/DMSans-Light.ttf"),
"DMSans-ExtraLight": require("../assets/fonts/DMSans-ExtraLight.ttf"),
"DMSans-Thin": require("../assets/fonts/DMSans-Thin.ttf"),
"DMSans-Black": require("../assets/fonts/DMSans-Black.ttf"),
"DMSans-ExtraBold": require("../assets/fonts/DMSans-ExtraBold.ttf"),
});
useEffect(() => {
setIsMounted(true);
const initializeAuth = async () => {
if (useAuthStore.persist.hasHydrated()) {
setHasHydrated(true);
} else {
const unsub = useAuthStore.persist.onFinishHydration(() => {
setHasHydrated(true);
});
return unsub;
}
};
initializeAuth();
}, []);
if (!isMounted || !hasHydrated || !fontsLoaded) {
return (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(255, 255, 255, 1)",
}}
>
<ActivityIndicator size="large" color="#ea580c" />
</View>
);
}
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<SirouRouterProvider config={routes} guards={[authGuard, guestGuard]}>
<ThemeProvider
value={colorScheme === "dark" ? NAV_THEME.dark : NAV_THEME.light}
>
<View className="flex-1 bg-background">
<StatusBar style={colorScheme === "dark" ? "light" : "dark"} />
<GlobalGuard />
<Stack
screenOptions={{
headerShown: false,
}}
>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="sms-scan"
options={{ headerShown: false }}
/>
<Stack.Screen
name="proforma/[id]"
options={{ title: "Proforma request" }}
/>
<Stack.Screen
name="proforma/edit"
options={{ title: "Edit Proforma" }}
/>
<Stack.Screen
name="invoices/[id]"
options={{ title: "Invoice" }}
/>
<Stack.Screen
name="invoices/edit"
options={{ title: "Edit Invoice" }}
/>
<Stack.Screen
name="payments/[id]"
options={{ title: "Payment" }}
/>
<Stack.Screen
name="notifications/index"
options={{ title: "Notifications" }}
/>
<Stack.Screen
name="notifications/settings"
options={{ title: "Notification settings" }}
/>
<Stack.Screen name="help" options={{ headerShown: false }} />
<Stack.Screen name="faq" options={{ headerShown: false }} />
<Stack.Screen name="terms" options={{ headerShown: false }} />
<Stack.Screen name="privacy" options={{ headerShown: false }} />
<Stack.Screen name="history" options={{ headerShown: false }} />
<Stack.Screen name="company" options={{ headerShown: false }} />
<Stack.Screen
name="company-details"
options={{ headerShown: false }}
/>
<Stack.Screen
name="login"
options={{ title: "Sign in", headerShown: false }}
/>
<Stack.Screen
name="register"
options={{ title: "Create account", headerShown: false }}
/>
<Stack.Screen
name="reports/index"
options={{ title: "Reports" }}
/>
<Stack.Screen
name="documents/index"
options={{ title: "Documents" }}
/>
<Stack.Screen name="settings" options={{ title: "Settings" }} />
<Stack.Screen name="profile" options={{ headerShown: false }} />
<Stack.Screen
name="edit-profile"
options={{ headerShown: false }}
/>
</Stack>
<SessionHeartbeat />
<PortalHost />
<Toast />
</View>
</ThemeProvider>
</SirouRouterProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}